diff --git a/.rnstorybook/preview.tsx b/.rnstorybook/preview.tsx
index 6cc89495278..d89a5eb10b2 100644
--- a/.rnstorybook/preview.tsx
+++ b/.rnstorybook/preview.tsx
@@ -2,8 +2,6 @@ import React from 'react';
import type { Preview } from '@storybook/react';
import { Provider } from 'react-redux';
-import { themes } from '../app/lib/constants/colors';
-import MessageContext from '../app/containers/message/Context';
import { selectServerRequest } from '../app/actions/server';
import { mockedStore as store } from '../app/reducers/mockedStore';
import { setUser } from '../app/actions/login';
@@ -19,26 +17,7 @@ const preview: Preview = {
decorators: [
Story => (
- {},
- onLongPress: () => {},
- reactionInit: () => {},
- onErrorPress: () => {},
- replyBroadcast: () => {},
- onReactionPress: () => {},
- onDiscussionPress: () => {},
- onReactionLongPress: () => {},
- threadBadgeColor: themes.light.badgeBackgroundLevel1
- }}>
-
-
+
)
]
diff --git a/CLAUDE.md b/CLAUDE.md
index e774d0998e3..70f9c68f993 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ Rocket.Chat React Native mobile client. Single-package React Native app (not a m
- React 19.1, React Native 0.81, Expo 54
- TypeScript with strict mode, baseUrl set to `app/` (imports resolve from there)
- Node: engines `>=18`, volta pins 24.13.1
-- Read UBIQUITOUS_LANGUAGE.md
+- Read CONTEXT.md
## Commands
diff --git a/UBIQUITOUS_LANGUAGE.md b/CONTEXT.md
similarity index 56%
rename from UBIQUITOUS_LANGUAGE.md
rename to CONTEXT.md
index 2b35c62a1d2..1399e8e8b1d 100644
--- a/UBIQUITOUS_LANGUAGE.md
+++ b/CONTEXT.md
@@ -2,39 +2,91 @@
## 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) | Membership, room entry |
-| **Channel** | A public Room (type `'c'`) visible to all server 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 (ambiguous) |
+| 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) | Membership, room entry |
+| **Channel** | A public Room (type `'c'`) visible to all server 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 (ambiguous) |
+| **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
-| Term | Definition | Aliases to avoid |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------ | ------------------- |
-| **Message** | A unit of communication within a Room, identified by `_id` with content in `msg` and parsed markdown in `md` | Chat message, text |
-| **Thread Message** | A Message that belongs to a Thread, identified by presence of `tmid` | Reply, thread reply |
-| **System Message** | A Message generated by the server to record events (user joined, room archived, role changed) — identified by `t` (type) field | Event, notification |
-| **Attachment** | Rich media or structured data embedded in a Message (image, video, audio, file, or action buttons) | File, media |
-| **Reaction** | An emoji response to a Message, tracking which usernames reacted | Emoji reaction |
-| **Mention** | An `@username` reference within a Message that triggers notifications | Tag, ping |
-| **Draft Message** | A user's unsent composition stored on a Subscription or Thread (`draftMessage` field) | Unsent message |
-| **Snippet** | A saved excerpt from a Message | — |
+### Core
+
+| Term | Definition | Aliases to avoid |
+| ------------------ | ------------------------------------------------------------------------------------------------------------ | --------------------------- |
+| **Message** | A unit of communication within a Room, identified by `_id` with content in `msg` and parsed markdown in `md` | Chat message, text |
+| **Thread Message** | A Message that belongs to a Thread, identified by presence of `tmid` | Reply, thread reply |
+| **Thread Parent** | The Message a Thread is spawned from; the target of its Thread Messages' `tmid` | Thread root, parent message |
+| **Attachment** | Rich media or structured data embedded in a Message (image, video, audio, file, or action buttons) | File, media |
+| **Reaction** | An emoji response to a Message, tracking which usernames reacted | Emoji reaction |
+| **Mention** | An `@username` reference within a Message that triggers notifications | Tag, ping |
+| **Draft Message** | A user's unsent composition stored on a Subscription or Thread (`draftMessage` field) | Unsent message |
+| **Snippet** | A saved excerpt from a Message | — |
+
+### System Messages
+
+A **System Message** is any server-generated Message (carrying a `t` type field) rather than a user-typed one. The code draws a hard boundary between room-event system messages and typed-event system messages — keep them apart.
+
+| Term | Definition | Aliases to avoid |
+| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
+| **System Message** | A server-generated Message identified by a `t` type field; umbrella term covering Info Messages and the typed events below | Event, notification |
+| **Info Message** | A System Message that records a room event (user joined, room archived, role changed, muted) — rendered compact and non-interactive. Excludes `e2e`, `discussion-created`, `jitsi_call_started`, and `videoconf` | System event, event message |
+| **Discussion-Created Message** | A System Message (`t = 'discussion-created'`) recording that a Discussion was spawned from this Message; rendered with a link to the new Room | — |
+| **Call Message** | A System Message (`t = 'jitsi_call_started'` or `t = 'videoconf'`) recording a Video Conference and offering a join affordance | Video call message |
+| **Encrypted Message** | A System Message (`t = 'e2e'`) whose content is pending E2E decryption (`e2e !== 'done'`); shown as an "Encrypted message" placeholder until decrypted | Pending E2E message |
+
+### Content & Visibility States
+
+| Term | Definition | Aliases to avoid |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
+| **Ignored Message** | A Message whose author is an Ignored User in this Room; shown as a "Message ignored" placeholder until tapped to reveal | Muted message |
+| **Ignored User** | A User the current user has hidden in a specific Room (`room.ignored` list); their Messages render as Ignored Messages | Muted user |
+| **Auto-Translate** | A per-Room setting that replaces other users' Message bodies with translations in the user's chosen language | Live translate |
+| **Translated Message** | A Message whose body is shown via its auto-translated text, because Auto-Translate is on for the Room and the author is another user | Auto-translated message |
+| **Blocks Message** | A Message whose body is structured Blocks from a Rocket.Chat App, rendered instead of markdown | App message |
+| **Message Preview** | A Message rendered outside its Room context (search, pinned, share extension, notifications); no interactions, reactions, or thread context | Preview row |
+
+## Message Grouping
+
+| Term | Definition | Aliases to avoid |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------- |
+| **Message Header** | The author block (avatar, name, timestamp) shown on the first Message of a grouped run; a Message either shows a Header or is grouped under the one above it | Title, byline |
+| **Grouped Message** | A Message rendered without its own Header because it continues a run from the same author within the Grouping Period, visually attached to the Message above | Sequential, collapsed |
+| **Grouping Period** | The maximum time gap between consecutive same-author Messages for them to share one Header (server setting `Message_GroupingPeriod`) | — |
+| **Previous Message** | Relative to a given Message, the adjacent older Message; whether that Message shows a Header is derived from it (author, time, status, thread) | Prior message, neighbor |
## Message Status
-| Term | Definition | Aliases to avoid |
-| ----------- | -------------------------------------------------------------------- | ---------------- |
-| **Sent** | Message successfully delivered to server (status `0`) | Delivered |
-| **Temp** | Message created locally but not yet confirmed by server (status `1`) | Pending, sending |
-| **Error** | Message that failed to send (status `2`) | Failed |
-| **Pinned** | Message flagged as important and pinned to the Room by a user | Bookmarked |
-| **Starred** | Message bookmarked by the current user for personal reference | Saved |
+The delivery lifecycle of a Message (`status` field). Exactly one status at a time; not to be confused with Message Flags.
+
+| Term | Definition | Aliases to avoid |
+| --------- | -------------------------------------------------------------------- | ---------------- |
+| **Sent** | Message successfully delivered to server (status `0`) | Delivered |
+| **Temp** | Message created locally but not yet confirmed by server (status `1`) | Pending, sending |
+| **Error** | Message that failed to send (status `2`) | Failed |
+
+## Message Flags
+
+Independent boolean markers on a Message, orthogonal to its Status — a Message can be both Pinned and Starred, and either regardless of delivery state.
+
+| Term | Definition | Aliases to avoid |
+| ----------- | --------------------------------------------------------------------------------------------- | ---------------- |
+| **Pinned** | A Message flagged as important for the whole Room; visible to all Members via the pinned list | Bookmarked |
+| **Starred** | A Message bookmarked by the current user for personal reference; visible only to that user | Saved |
+
+## Message Separators
+
+| Term | Definition | Aliases to avoid |
+| -------------------- | ------------------------------------------------------------------------------------------------ | ---------------- |
+| **Date Separator** | A divider rendered between two Messages on different days, showing the date of the newer Message | Date divider |
+| **Unread Separator** | A divider rendered between the last read Message and the first unread Message in a Room | Unread divider |
## Message Loading
@@ -52,6 +104,25 @@
| **Room History** | Older Messages of a Room fetched on demand from the server (distinct from **Server History**) | Message history |
| **Jump to Message** | Re-position the Room view onto a target Message that may be far from the Live Tail or not yet synced — fetches a surrounding Chunk | Scroll to message |
+## Message Action & Position State
+
+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 |
+
+### Message Actions
+
+A **Message Action** is the active mode on a Message in the Room view. The three actions are **Quote**, **Edit**, and **React** — there is no "reply" action; replying in a Thread is a separate navigation, not a Message Action.
+
+| Term | Definition | Aliases to avoid |
+| --------- | --------------------------------------------------------------------------------------- | ---------------- |
+| **Quote** | A Message Action where one or more Messages are selected to be quoted into the composer | Multi-quote |
+| **Edit** | A Message Action where a single Message is being edited by the current user | Editing |
+| **React** | A Message Action where a single Message is the target of a reaction picker | Reacting |
+
## Users & Roles
| Term | Definition | Aliases to avoid |
@@ -140,12 +211,15 @@
- A **Room** can be of type **Channel**, **Group**, **Direct Message**, or **Omnichannel Room**
- A **Subscription** belongs to exactly one **Room** and one **User**
- A **Message** belongs to exactly one **Room** (via `rid`)
-- A **Thread** is spawned from exactly one **Message** and contains one or more **Thread Messages**
-- A **Discussion** creates a new **Room** linked to a parent **Room** (via `prid`)
+- A **Thread** is spawned from exactly one **Thread Parent** and contains one or more **Thread Messages** that reference it via `tmid`
+- A **Discussion** creates a new **Room** linked to a parent **Room** (via `prid`); a **Discussion-Created Message** in the parent records the spawning
- A **Team** has exactly one main **Room** and can contain multiple **Channels**
- An **Omnichannel Room** connects exactly one **Visitor** with zero or one **Agents** (via **Served By**)
- An **Agent** belongs to one or more **Departments**
- An **Inquiry** becomes an **Omnichannel Room** when picked up by an **Agent**
+- A **Broadcast Room** restricts sending to authorized users; others interact via **Reply Broadcast**
+- An **Ignored Message** is a **Message** whose author is an **Ignored User** in the current **Room**; revealing it is per-Message and ephemeral
+- A **Call Message** records a **Video Conference** and offers a join affordance
- A **Room** view shows a **Live Window** by default; a **Jump to Message** replaces it with an **Anchored Window**
- A **Gap** is bracketed by **Loader Rows**; resolving a Loader Row fetches a **Chunk** and may shrink or close the Gap
- **Jump to Message** fetches a **Chunk** centered on the target (`loadSurroundingMessages`), bracketed by an **Older Loader** and a **Newer Loader** when more Messages exist on either side
@@ -169,3 +243,10 @@
- **"History"** is overloaded: **Server History** is the recent-Servers reconnection list; **Room History** is older Messages fetched on demand. The action `roomHistoryRequest` and saga `ROOM.HISTORY_REQUEST` refer to **Room History**.
- **"Window"** is used metaphorically in the Subscriptions dialogue ("a Subscription is the user's window into it"); a **Message Window** is the concrete observed Message range in the Room view. Disambiguate when both could be meant.
- **"Load more"** is directional: older Messages are an **Older Loader** (`MORE`/`PREVIOUS_CHUNK`), newer Messages are a **Newer Loader** (`NEXT_CHUNK`). Avoid bare "load more".
+- **"System message" vs "Info message"** — **System Message** is the umbrella (any `t`-bearing server Message); **Info Message** is the narrower set of room-event System Messages. The typed events `e2e`, `discussion-created`, `jitsi_call_started`, and `videoconf` are System Messages but NOT Info Messages — each gets its own rendering branch.
+- **"Thread reply"** is overloaded. The glossary's **Thread Message** is the data concept (any Message with `tmid`); the code's `isThreadReply` is a _rendering position_ — the first Thread Message in a run shown in the parent Room, which gets the "in reply to" header. Do not use "thread reply" for the data concept.
+- **"Preview"** is overloaded. **Message Preview** (`isPreview`) is a Message rendered outside its Room (search, pinned, share, notifications). `PreviewContent` is a different concept: the compact body of a Thread Message shown in the parent Room. Disambiguate when either could be meant.
+- **"Muted"** is overloaded: a User can be muted in a Room (a moderator action that removes send permission, recorded by `user-muted`/`mute_unmute` System Messages) OR be an **Ignored User** (a per-viewer filter that hides their Messages behind an Ignored Message placeholder, stored in `room.ignored`). Muting is a room permission; ignoring is a personal filter. Different concepts — keep them apart.
+- **"Reply"** is overloaded: **Reply Broadcast** is the action available to non-authorized users in a Broadcast Room; replying in a **Thread** is navigation into the Thread view. Neither is a **Message Action** — there is no "reply" Message Action.
+- **"Status" vs "flags"** — a Message has exactly one delivery **Status** (Sent, Temp, Error). **Pinned** and **Starred** are independent **Message Flags**, not statuses; do not group them with delivery states.
+- **"Interaction" retired** — the selection-plus-action state was once an "interaction" concept; the canonical term is now **Message Action State**. Use **Message Action**, not "interaction", for which Message is selected and how. (Selection is not separate — it lives inside the active Message Action.)
diff --git a/app/containers/MessageComposer/MessageComposer.test.tsx b/app/containers/MessageComposer/MessageComposer.test.tsx
index 1d7dfb8c20b..fdcf1a973e0 100644
--- a/app/containers/MessageComposer/MessageComposer.test.tsx
+++ b/app/containers/MessageComposer/MessageComposer.test.tsx
@@ -10,9 +10,10 @@ import { selectServerRequest } from '../../actions/server';
import { setUser } from '../../actions/login';
import { mockedStore } from '../../reducers/mockedStore';
import { type IPermissionsState } from '../../reducers/permissions';
-import { type IMessage, type IShareAttachment } from '../../definitions';
+import { type IMessage, type IShareAttachment, type TMessageActionState } from '../../definitions';
import { colors } from '../../lib/constants/colors';
import { type IRoomContext, RoomContext } from '../../views/RoomView/context';
+import { MessageActionProvider } from '../message/stores/MessageActionStore';
import * as EmojiKeyboardHook from './hooks/useEmojiKeyboard';
import { initStore } from '../../lib/store/auxStore';
import { searchRemote } from '../../lib/methods/search';
@@ -110,24 +111,32 @@ const initialContext = {
federated: false
},
sharing: false,
- action: null,
- selectedMessages: [],
editCancel: jest.fn(),
editRequest: jest.fn(),
onSendMessage: jest.fn(),
onRemoveQuoteMessage: jest.fn()
};
-const Render = ({ context, children }: { context?: Partial; children?: ReactElement }) => (
+const Render = ({
+ context,
+ action,
+ children
+}: {
+ context?: Partial;
+ action?: TMessageActionState;
+ children?: ReactElement;
+}) => (
-
-
- <>
-
- {children}
- >
-
-
+
+
+
+ <>
+
+ {children}
+ >
+
+
+
);
@@ -639,7 +648,7 @@ describe('MessageComposer', () => {
const id = 'messageId';
beforeEach(() => {
return renderAndFlush(
-
+
);
});
test('init', async () => {
@@ -671,7 +680,7 @@ describe('MessageComposer', () => {
const editRequest = jest.fn();
const id = 'image';
test('edit image', async () => {
- await renderAndFlush();
+ await renderAndFlush();
await screen.findByTestId('message-composer');
await user.press(screen.getByTestId('message-composer-send'));
expect(editRequest).toHaveBeenCalledWith({ id, msg: `Attachment description for ${id}`, rid: 'rid' });
@@ -703,14 +712,14 @@ describe('MessageComposer', () => {
describe('Quote', () => {
test('Add quote `abc`', async () => {
- render();
+ render();
await screen.findByTestId('composer-quote-abc');
expect(screen.queryByTestId('composer-quote-abc')).toBeOnTheScreen();
expect(screen.toJSON()).toMatchSnapshot();
});
test('Add quote `def`', async () => {
- render();
+ render();
await screen.findByTestId('composer-quote-abc');
expect(screen.queryByTestId('composer-quote-abc')).toBeOnTheScreen();
expect(screen.queryByTestId('composer-quote-def')).toBeOnTheScreen();
@@ -719,7 +728,7 @@ describe('MessageComposer', () => {
test('Remove a quote', async () => {
const onRemoveQuoteMessage = jest.fn();
- render();
+ render();
await screen.findByTestId('composer-quote-def');
await user.press(screen.getByTestId('composer-quote-remove-def'));
expect(onRemoveQuoteMessage).toHaveBeenCalledTimes(1);
diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx
index f9367890e4d..40ee12556aa 100644
--- a/app/containers/MessageComposer/MessageComposer.tsx
+++ b/app/containers/MessageComposer/MessageComposer.tsx
@@ -5,6 +5,7 @@ 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 { Autocomplete } from './components';
import { MIN_HEIGHT } from './constants';
import {
@@ -54,7 +55,8 @@ export const MessageComposer = ({
});
const contentHeight = useSharedValue(MIN_HEIGHT);
useCloseKeyboardWhenOrientationChanges();
- const { rid, tmid, action, selectedMessages, sharing, editRequest, onSendMessage, setQuotesAndText } = useRoomContext();
+ const { rid, tmid, sharing, editRequest, onSendMessage, setQuotesAndText } = useRoomContext();
+ const action = useMessageAction();
const alsoSendThreadToChannel = useAlsoSendThreadToChannel();
const { showEmojiKeyboard, showEmojiSearchbar, openEmojiSearchbar, resetKeyboard, keyboardHeight } = useEmojiKeyboard();
const { setAlsoSendThreadToChannel, setAutocompleteParams, clearAttachments } = useMessageComposerApi();
@@ -108,13 +110,13 @@ export const MessageComposer = ({
const textFromInput = composerInputComponentRef.current.getTextAndClear();
- if (action === 'edit') {
+ if (action?.kind === 'edit') {
const updatedAttachments = attachments.length
? attachments.map(({ description, altText, fileId, filename }) =>
altTextSupported ? { description: altText || '', fileId, filename } : { description: description || '' }
)
: undefined;
- editRequest?.({ id: selectedMessages[0], msg: textFromInput, rid, attachments: updatedAttachments });
+ editRequest?.({ id: action.messageId, msg: textFromInput, rid, attachments: updatedAttachments });
clearAttachments();
return;
}
@@ -122,8 +124,8 @@ export const MessageComposer = ({
if (attachments.length) {
let quotedMessage: string | undefined;
- if (action === 'quote') {
- quotedMessage = await prepareQuoteMessage(textFromInput, selectedMessages);
+ if (action?.kind === 'quote') {
+ quotedMessage = await prepareQuoteMessage(textFromInput, action.messageIds);
}
try {
@@ -146,8 +148,8 @@ export const MessageComposer = ({
}
}
- if (action === 'quote') {
- const quoteMessage = await prepareQuoteMessage(textFromInput, selectedMessages);
+ if (action?.kind === 'quote') {
+ const quoteMessage = await prepareQuoteMessage(textFromInput, action.messageIds);
onSendMessage?.(quoteMessage);
return;
}
@@ -245,7 +247,7 @@ export const MessageComposer = ({
}}>
{
'use memo';
- const { action, editCancel } = useRoomContext();
+ const { editCancel } = useRoomContext();
+ const action = useMessageAction();
- if (action !== 'edit') {
+ if (action?.kind !== 'edit') {
return null;
}
return (
diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx
index 8bc8eed21cd..a0547bab0a6 100644
--- a/app/containers/MessageComposer/components/ComposerInput.tsx
+++ b/app/containers/MessageComposer/components/ComposerInput.tsx
@@ -33,6 +33,7 @@ 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 '../../message/stores/MessageActionStore';
import { getMessageById } from '../../../lib/database/services/Message';
import { generateTriggerId } from '../../../lib/methods/actions';
import { executeCommandPreview } from '../../../lib/services/restApi';
@@ -50,7 +51,8 @@ const defaultSelection: IInputSelection = { start: 0, end: 0 };
export const ComposerInput = memo(
forwardRef(({ inputRef }, ref) => {
const { colors, theme } = useTheme();
- const { rid, tmid, sharing, action, selectedMessages, setQuotesAndText, room } = useRoomContext();
+ const { rid, tmid, sharing, setQuotesAndText, room } = useRoomContext();
+ const action = useMessageAction();
const focused = useFocused();
const { setFocused, setMicOrSend, setAutocompleteParams } = useMessageComposerApi();
const autocompleteType = useAutocompleteParams()?.type;
@@ -92,18 +94,18 @@ export const ComposerInput = memo(
}
};
- if (action !== 'edit' && firstRender.current) {
+ if (action?.kind !== 'edit' && firstRender.current) {
firstRender.current = false;
setDraftMessage();
}
if (sharing) return;
if (usedCannedResponse) setInput(usedCannedResponse);
- }, [action, rid, tmid, usedCannedResponse]);
+ }, [action?.kind, rid, tmid, usedCannedResponse]);
// Edit/quote
useEffect(() => {
- const fetchMessageAndSetInput = async () => {
- const message = await getMessageById(selectedMessages[0]);
+ const fetchMessageAndSetInput = async (messageId: string) => {
+ const message = await getMessageById(messageId);
if (message) {
setInput(message?.msg || (altTextSupported ? '' : message?.attachments?.[0]?.description || ''));
}
@@ -111,19 +113,19 @@ export const ComposerInput = memo(
if (sharing) return;
- if (prevAction === 'edit' && action !== 'edit') {
+ if (prevAction?.kind === 'edit' && action?.kind !== 'edit') {
setInput('');
return;
}
- if (action === 'edit' && selectedMessages[0]) {
+ if (action?.kind === 'edit') {
focus();
- fetchMessageAndSetInput();
+ fetchMessageAndSetInput(action.messageId);
return;
}
- if (action === 'quote' && selectedMessages.length) {
+ if (action?.kind === 'quote' && action.messageIds.length) {
focus();
}
- }, [action, selectedMessages]);
+ }, [action]);
useFocusEffect(
useCallback(() => {
diff --git a/app/containers/MessageComposer/components/MessageComposerContent.tsx b/app/containers/MessageComposer/components/MessageComposerContent.tsx
index 7c729c54c7a..d7d3498bc3c 100644
--- a/app/containers/MessageComposer/components/MessageComposerContent.tsx
+++ b/app/containers/MessageComposer/components/MessageComposerContent.tsx
@@ -1,7 +1,7 @@
import { memo, type ReactElement, type RefObject } from 'react';
import { type LayoutChangeEvent, StyleSheet, View } from 'react-native';
-import { type TMessageAction } from '../../../views/RoomView/context';
+import { type TMessageAction } from '../../../definitions';
import { type IComposerInput } from '../interfaces';
import { useTheme } from '../../../theme';
import { RecordAudio } from './RecordAudio';
diff --git a/app/containers/MessageComposer/components/Quotes/Quotes.tsx b/app/containers/MessageComposer/components/Quotes/Quotes.tsx
index 7eadcf4f5ed..e8a54af866d 100644
--- a/app/containers/MessageComposer/components/Quotes/Quotes.tsx
+++ b/app/containers/MessageComposer/components/Quotes/Quotes.tsx
@@ -2,12 +2,13 @@ import { useEffect, useRef, type ReactElement } from 'react';
import { FlatList } from 'react-native';
import { Quote } from './Quote';
-import { useRoomContext } from '../../../../views/RoomView/context';
+import { useMessageAction } from '../../../message/stores/MessageActionStore';
export const Quotes = (): ReactElement | null => {
'use memo';
- const { selectedMessages, action } = useRoomContext();
+ const action = useMessageAction();
+ const selectedMessages = action?.kind === 'quote' ? action.messageIds : [];
const nQuotesRef = useRef(0);
const listRef = useRef(null);
@@ -20,7 +21,7 @@ export const Quotes = (): ReactElement | null => {
nQuotesRef.current = selectedMessages.length;
}, [selectedMessages.length]);
- if (action !== 'quote') {
+ if (action?.kind !== 'quote') {
return null;
}
diff --git a/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts b/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts
index 5c547542366..4c3d538c704 100644
--- a/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts
+++ b/app/containers/MessageComposer/hooks/useAutoSaveDraft.ts
@@ -3,13 +3,15 @@ import { useCallback, useEffect, useRef } from 'react';
import { saveDraftMessage } from '../../../lib/methods/draftMessage';
import { useRoomContext } from '../../../views/RoomView/context';
+import { useMessageAction } from '../../message/stores/MessageActionStore';
import { useFocused } from '../context';
export const useAutoSaveDraft = (text = '') => {
'use memo';
const route = useRoute();
- const { rid, tmid, action, selectedMessages } = useRoomContext();
+ const { rid, tmid } = useRoomContext();
+ const action = useMessageAction();
const focused = useFocused();
const oldText = useRef('');
const intervalRef = useRef(null);
@@ -19,11 +21,13 @@ export const useAutoSaveDraft = (text = '') => {
const saveMessageDraft = useCallback(
(m?: string) => {
if (route.name === 'ShareView') return;
- if (action === 'edit') return;
+ if (action?.kind === 'edit') return;
let draftMessage = '';
- if (selectedMessages?.length) {
- draftMessage = JSON.stringify({ quotes: selectedMessages, msg: text });
+ if (action?.kind === 'quote') {
+ draftMessage = JSON.stringify({ quotes: action.messageIds, msg: text });
+ } else if (action?.kind === 'react') {
+ draftMessage = JSON.stringify({ quotes: [action.messageId], msg: text });
} else {
draftMessage = m ?? text;
}
@@ -32,7 +36,7 @@ export const useAutoSaveDraft = (text = '') => {
saveDraftMessage({ rid, tmid, draftMessage });
}
},
- [action, rid, tmid, text, selectedMessages, route.name]
+ [action, rid, tmid, text, route.name]
);
// if focused on composer input, saves every N seconds
diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx
index a1f52c7d728..da1ddb62bae 100644
--- a/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx
+++ b/app/containers/MessageComposer/hooks/useChooseMedia.test.tsx
@@ -18,6 +18,10 @@ jest.mock('../../../views/RoomView/context', () => ({
useRoomContext: jest.fn()
}));
+jest.mock('../../message/stores/MessageActionStore', () => ({
+ useMessageAction: jest.fn()
+}));
+
jest.mock('../../../lib/hooks/useAltTextSupported', () => ({
useAltTextSupported: jest.fn()
}));
@@ -46,6 +50,7 @@ const mockGetDocumentAsync = require('expo-document-picker').getDocumentAsync as
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('../../message/stores/MessageActionStore').useMessageAction 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;
@@ -67,11 +72,10 @@ describe('useChooseMedia', () => {
);
mockUseMessageComposerApi.mockReturnValue({ addAttachments });
mockUseRoomContext.mockReturnValue({
- action: null,
setQuotesAndText: jest.fn(),
- selectedMessages: [],
getText: jest.fn(() => 'draft')
});
+ mockUseMessageAction.mockReturnValue(null);
mockGetSubscriptionByRoomId.mockResolvedValue({ rid: 'room-id', t: 'c' });
mockGetThreadById.mockResolvedValue({ id: 'thread-id' });
});
@@ -127,4 +131,40 @@ describe('useChooseMedia', () => {
});
expect(mockNavigate).not.toHaveBeenCalled();
});
+
+ it('forwards quoted message ids to ShareView as selectedMessages', async () => {
+ mockUseAltTextSupported.mockReturnValue(false);
+ mockUseMessageAction.mockReturnValue({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] });
+ mockGetDocumentAsync.mockResolvedValue({
+ canceled: false,
+ assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }]
+ });
+
+ const { result } = renderHook(() => useChooseMedia({ rid: 'room-id', tmid: 'thread-id', permissionToUpload: true }));
+
+ await result.current.chooseFile();
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
+ const { action, startShareView } = mockNavigate.mock.calls[0][1];
+ expect(action).toBe('quote');
+ expect(startShareView().selectedMessages).toEqual(['msg-1', 'msg-2']);
+ });
+
+ it('does not quote the message when the action is edit', async () => {
+ mockUseAltTextSupported.mockReturnValue(false);
+ mockUseMessageAction.mockReturnValue({ kind: 'edit', messageId: 'msg-1' });
+ mockGetDocumentAsync.mockResolvedValue({
+ canceled: false,
+ assets: [{ name: 'legacy.pdf', size: 12, mimeType: 'application/pdf', uri: 'file:///tmp/legacy.pdf' }]
+ });
+
+ const { result } = renderHook(() => useChooseMedia({ rid: 'room-id', tmid: 'thread-id', permissionToUpload: true }));
+
+ await result.current.chooseFile();
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalled());
+ const { action, startShareView } = mockNavigate.mock.calls[0][1];
+ expect(action).toBe('edit');
+ expect(startShareView().selectedMessages).toEqual([]);
+ });
});
diff --git a/app/containers/MessageComposer/hooks/useChooseMedia.ts b/app/containers/MessageComposer/hooks/useChooseMedia.ts
index dad98c7be54..626737fd53f 100644
--- a/app/containers/MessageComposer/hooks/useChooseMedia.ts
+++ b/app/containers/MessageComposer/hooks/useChooseMedia.ts
@@ -10,6 +10,7 @@ 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 '../../message/stores/MessageActionStore';
import { type IShareAttachment } from '../../../definitions';
import ImagePicker, { type ImageOrVideo } from '../../../lib/methods/helpers/ImagePicker/ImagePicker';
import { useMessageComposerApi } from '../context';
@@ -31,7 +32,8 @@ export const useChooseMedia = ({
const { FileUpload_MediaTypeWhiteList, FileUpload_MaxFileSize } = useAppSelector(state => state.settings);
const { addAttachments } = useMessageComposerApi();
- const { action, setQuotesAndText, selectedMessages, getText } = useRoomContext();
+ const { setQuotesAndText, getText } = useRoomContext();
+ const action = useMessageAction();
const altTextSupported = useAltTextSupported();
const allowList = FileUpload_MediaTypeWhiteList as string;
const maxFileSize = FileUpload_MaxFileSize as number;
@@ -94,6 +96,7 @@ export const useChooseMedia = ({
const startShareView = () => {
const text = getText?.() || '';
+ const selectedMessages = action?.kind === 'quote' ? action.messageIds : [];
return {
selectedMessages,
text
@@ -115,7 +118,7 @@ export const useChooseMedia = ({
room,
thread: thread || tmid,
attachments,
- action,
+ action: action?.kind ?? null,
finishShareView,
startShareView
});
diff --git a/app/containers/ReactionsList/AllTab.tsx b/app/containers/ReactionsList/AllTab.tsx
index 92ee7e9427e..449bd27c33d 100644
--- a/app/containers/ReactionsList/AllTab.tsx
+++ b/app/containers/ReactionsList/AllTab.tsx
@@ -1,7 +1,7 @@
import { memo, type ReactElement } from 'react';
import { Text, View, FlatList } from 'react-native';
-import Emoji from '../message/Emoji';
+import Emoji from '../message/components/Emoji';
import { useTheme } from '../../theme';
import { type IReaction } from '../../definitions';
import I18n from '../../i18n';
diff --git a/app/containers/ReactionsList/ReactionsList.stories.tsx b/app/containers/ReactionsList/ReactionsList.stories.tsx
index 9719194310d..d88d86fb6ad 100644
--- a/app/containers/ReactionsList/ReactionsList.stories.tsx
+++ b/app/containers/ReactionsList/ReactionsList.stories.tsx
@@ -1,17 +1,16 @@
import { View } from 'react-native';
+import { type ICustomEmojis } from '../../definitions';
import ReactionsList from '.';
import { mockedStore as store } from '../../reducers/mockedStore';
import { updateSettings } from '../../actions/settings';
import { setCustomEmojis } from '../../actions/customEmojis';
-store.dispatch(
- setCustomEmojis({
- marioparty: { name: 'marioparty', extension: 'gif' },
- react_rocket: { name: 'react_rocket', extension: 'png' },
- nyan_rocket: { name: 'nyan_rocket', extension: 'png' }
- })
-);
+const customEmojis: ICustomEmojis = {
+ marioparty: { name: 'marioparty', extension: 'gif' },
+ react_rocket: { name: 'react_rocket', extension: 'png' },
+ nyan_rocket: { name: 'nyan_rocket', extension: 'png' }
+};
const reactions = [
{
@@ -48,6 +47,7 @@ const reactions = [
export const ReactionsListStory = () => {
store.dispatch(updateSettings('UI_Use_Real_Name', false));
+ store.dispatch(setCustomEmojis(customEmojis));
return (
@@ -57,6 +57,7 @@ export const ReactionsListStory = () => {
export const ReactionsListFullName = () => {
store.dispatch(updateSettings('UI_Use_Real_Name', true));
+ store.dispatch(setCustomEmojis(customEmojis));
return (
diff --git a/app/containers/ReactionsList/index.tsx b/app/containers/ReactionsList/index.tsx
index ea7748fd4a2..7f9e620a8f6 100644
--- a/app/containers/ReactionsList/index.tsx
+++ b/app/containers/ReactionsList/index.tsx
@@ -6,7 +6,7 @@ import styles from './styles';
import AllTab from './AllTab';
import UsersList from './UsersList';
import { TabView } from '../TabView';
-import Emoji from '../message/Emoji';
+import Emoji from '../message/components/Emoji';
interface IReactionsListProps {
reactions?: IReaction[];
@@ -28,7 +28,7 @@ const useRoutes = (reactions: IReaction[] | undefined) => {
};
}
- const sortedReactions = reactions?.sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length);
+ const sortedReactions = [...reactions].sort((reaction1, reaction2) => reaction2.usernames.length - reaction1.usernames.length);
const routes: IRoute[] = sortedReactions.map(reaction => ({
key: reaction.emoji,
title: reaction.emoji,
diff --git a/app/containers/ThreadDetails.tsx b/app/containers/ThreadDetails.tsx
index 08c6ce78005..8eaa1e3a322 100644
--- a/app/containers/ThreadDetails.tsx
+++ b/app/containers/ThreadDetails.tsx
@@ -46,7 +46,7 @@ interface IThreadDetails {
id: string;
};
badgeColor?: string;
- toggleFollowThread: Function;
+ toggleFollowThread?: (isFollowingThread: boolean, tmid: string) => Promise | void;
style: ViewStyle;
}
@@ -62,7 +62,7 @@ const ThreadDetails = ({ item, user, badgeColor, toggleFollowThread, style }: IT
replies = '+999';
}
- const isFollowing = item.replies?.find((u: string) => u === user?.id);
+ const isFollowing = !!item.replies?.find((u: string) => u === user?.id);
return (
diff --git a/app/containers/UIKit/Image.tsx b/app/containers/UIKit/Image.tsx
index a2f7bd4e724..ce8539b10e9 100644
--- a/app/containers/UIKit/Image.tsx
+++ b/app/containers/UIKit/Image.tsx
@@ -2,7 +2,7 @@ import { StyleSheet, View } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { BlockContext } from '@rocket.chat/ui-kit';
-import ImageContainer from '../message/Components/Attachments/Image';
+import ImageContainer from '../message/components/Attachments/Image';
import Navigation from '../../lib/navigation/appNavigation';
import { type IThumb, type IImage } from './interfaces';
import { type IAttachment } from '../../definitions';
diff --git a/app/containers/UIKit/UiKitMessage.stories.tsx b/app/containers/UIKit/UiKitMessage.stories.tsx
index 88b8bb4f756..dc386700e54 100644
--- a/app/containers/UIKit/UiKitMessage.stories.tsx
+++ b/app/containers/UIKit/UiKitMessage.stories.tsx
@@ -1,9 +1,8 @@
import { ScrollView, View } from 'react-native';
import { type ReactElement } from 'react';
-import MessageContext from '../message/Context';
import { UiKitMessage } from '.';
-import { themes, colors } from '../../lib/constants/colors';
+import { colors } from '../../lib/constants/colors';
import { longText } from '../../../.rnstorybook/utils';
import {
BASE_ROW_HEIGHT,
@@ -12,14 +11,6 @@ import {
} from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
import { ThemeContext, type TSupportedThemes } from '../../theme';
-const user = {
- id: 'y8bd77ptZswPj3EW8',
- username: 'diego.mello',
- token: 'abc'
-};
-
-const baseUrl = 'https://open.rocket.chat';
-
export default {
title: 'UIKit/UiKitMessage',
decorators: [
@@ -34,22 +25,7 @@ export default {
width: 350,
height: 800
}}>
- {},
- onLongPress: () => {},
- reactionInit: () => {},
- onErrorPress: () => {},
- replyBroadcast: () => {},
- onReactionPress: () => {},
- onDiscussionPress: () => {},
- onReactionLongPress: () => {},
- threadBadgeColor: themes.light.fontInfo
- }}>
-
-
+
)
]
diff --git a/app/containers/UIKit/UiKitModal.stories.tsx b/app/containers/UIKit/UiKitModal.stories.tsx
index 21ccea85885..adee3d2d256 100644
--- a/app/containers/UIKit/UiKitModal.stories.tsx
+++ b/app/containers/UIKit/UiKitModal.stories.tsx
@@ -3,37 +3,19 @@ import { Button, View } from 'react-native';
import { UiKitComponent, UiKitModal } from '.';
import { KitContext, defaultContext } from './utils';
-import MessageContext from '../message/Context';
-import { themes } from '../../lib/constants/colors';
-
-const user = {
- id: 'y8bd77ptZswPj3EW8',
- username: 'diego.mello',
- token: 'abc'
-};
-
-const baseUrl = 'https://open.rocket.chat';
+import { MessageRoomProvider } from '../message/stores/MessageRoomStore';
+import { MessageProvider } from '../message/stores/MessageStore';
+import { type TAnyMessageModel } from '../../definitions';
export default {
title: 'UIKit/UiKitModal',
decorators: [
(Story: any) => (
- {},
- onLongPress: () => {},
- reactionInit: () => {},
- onErrorPress: () => {},
- replyBroadcast: () => {},
- onReactionPress: () => {},
- onDiscussionPress: () => {},
- onReactionLongPress: () => {},
- threadBadgeColor: themes.light.fontInfo
- }}>
-
-
+
+
+
+
+
)
]
};
@@ -302,48 +284,47 @@ export const ModalFormTextArea = () =>
]);
ModalFormTextArea.storyName = 'Modal - Form TextArea';
-// FIXME: Commented out because it's breaking jest snapshots
-// export const ModalImages = () =>
-// UiKitModal([
-// {
-// type: 'image',
-// title: {
-// type: 'plain_text',
-// text: 'Example Image',
-// emoji: true
-// },
-// imageUrl: 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/icon-circle-256.png',
-// alt_text: 'Example Image'
-// },
-// {
-// type: 'section',
-// text: {
-// type: 'mrkdwn',
-// text: 'How could be the life in Mars?'
-// }
-// },
-// {
-// type: 'context',
-// elements: [
-// {
-// type: 'image',
-// imageUrl: 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/icon-circle-256.png'
-// },
-// {
-// type: 'mrkdwn',
-// text: 'November 25, 2019'
-// }
-// ]
-// },
-// {
-// type: 'section',
-// text: {
-// type: 'mrkdwn',
-// text: '*Next stop, Mars!*\nMussum Ipsum, cacilds vidis litro abertis. Admodum accumsan disputationi eu sit. Vide electram sadipscing et per. Diuretics paradis num copo é motivis de denguis. Mais vale um bebadis conhecidiss, que um alcoolatra anonimis. Aenean aliquam molestie leo, vitae iaculis nisl.'
-// }
-// }
-// ]);
-// ModalImages.storyName = 'Modal - Images';
+export const ModalImages = () =>
+ UiKitModal([
+ {
+ type: 'image',
+ title: {
+ type: 'plain_text',
+ text: 'Example Image',
+ emoji: true
+ },
+ imageUrl: 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/icon-circle-256.png',
+ alt_text: 'Example Image'
+ },
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: 'How could be the life in Mars?'
+ }
+ },
+ {
+ type: 'context',
+ elements: [
+ {
+ type: 'image',
+ imageUrl: 'https://raw.githubusercontent.com/RocketChat/Rocket.Chat.Artwork/master/Logos/icon-circle-256.png'
+ },
+ {
+ type: 'mrkdwn',
+ text: 'November 25, 2019'
+ }
+ ]
+ },
+ {
+ type: 'section',
+ text: {
+ type: 'mrkdwn',
+ text: '*Next stop, Mars!*\nMussum Ipsum, cacilds vidis litro abertis. Admodum accumsan disputationi eu sit. Vide electram sadipscing et per. Diuretics paradis num copo é motivis de denguis. Mais vale um bebadis conhecidiss, que um alcoolatra anonimis. Aenean aliquam molestie leo, vitae iaculis nisl.'
+ }
+ }
+ ]);
+ModalImages.storyName = 'Modal - Images';
export const ModalActions = () =>
UiKitModal([
diff --git a/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap b/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
index b5a459e7869..a098c73ec9f 100644
--- a/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
+++ b/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
@@ -3448,6 +3448,468 @@ exports[`Story Snapshots: ModalFormTextArea should match snapshot 1`] = `
]
`;
+exports[`Story Snapshots: ModalImages should match snapshot 1`] = `
+[
+
+
+
+
+
+
+
+
+
+
+
+ ,
+
+
+
+
+
+
+ How could be the life in Mars?
+
+
+
+
+
+ ,
+
+
+
+
+
+ November 25, 2019
+
+ ,
+
+
+
+
+
+
+
+ Next stop, Mars!
+
+
+
+
+
+
+
+ Mussum Ipsum, cacilds vidis litro abertis. Admodum accumsan disputationi eu sit. Vide electram sadipscing et per. Diuretics paradis num copo é motivis de denguis. Mais vale um bebadis conhecidiss, que um alcoolatra anonimis. Aenean aliquam molestie leo, vitae iaculis nisl.
+
+
+
+
+
+ ,
+]
+`;
+
exports[`Story Snapshots: ModalInputWithAddField should match snapshot 1`] = `
{
- 'use memo';
-
- const { user, replyBroadcast } = useContext(MessageContext);
- const { theme } = useTheme();
- const isOwn = author?._id === user.id;
-
- if (broadcast && !isOwn) {
- return (
-
-
-
-
- {I18n.t('Reply')}
-
-
-
- );
- }
- return null;
-});
-
-Broadcast.displayName = 'MessageBroadcast';
-
-export default Broadcast;
diff --git a/app/containers/message/CallButton.tsx b/app/containers/message/CallButton.tsx
deleted file mode 100644
index ae3b841109a..00000000000
--- a/app/containers/message/CallButton.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { memo } from 'react';
-import { Text, View } from 'react-native';
-
-import Touchable from './Touchable';
-import { BUTTON_HIT_SLOP } from './utils';
-import styles from './styles';
-import I18n from '../../i18n';
-import { CustomIcon } from '../CustomIcon';
-import { themes } from '../../lib/constants/colors';
-import { type IMessageCallButton } from './interfaces';
-import { useTheme } from '../../theme';
-
-// TODO: Create a reusable button component for message
-const CallButton = memo(({ handleEnterCall }: IMessageCallButton) => {
- 'use memo';
-
- const { theme } = useTheme();
- return (
-
-
-
-
- {I18n.t('Click_to_join')}
-
-
-
- );
-});
-
-CallButton.displayName = 'CallButton';
-
-export default CallButton;
diff --git a/app/containers/message/Components/Attachments/Attachments.tsx b/app/containers/message/Components/Attachments/Attachments.tsx
deleted file mode 100644
index 68b2c173535..00000000000
--- a/app/containers/message/Components/Attachments/Attachments.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { useContext, memo, type FC } from 'react';
-import { dequal } from 'dequal';
-import { View } from 'react-native';
-
-import Image from './Image';
-import Audio from './Audio';
-import Video from './Video';
-import CollapsibleQuote from './CollapsibleQuote';
-import AttachedActions from './AttachedActions';
-import Reply from './Reply';
-import MessageContext from '../../Context';
-import { type IMessageAttachments } from '../../interfaces';
-import { getMessageFromAttachment } from '../../utils';
-import { isContentAttachment } from './utils';
-
-const Attachments: FC = memo(
- ({ attachments, timeFormat, showAttachment, author }: IMessageAttachments) => {
- 'use memo';
-
- const { translateLanguage } = useContext(MessageContext);
-
- const nonQuoteAttachments = attachments?.filter(isContentAttachment);
-
- if (!nonQuoteAttachments || nonQuoteAttachments.length === 0) {
- return null;
- }
-
- const attachmentsElements = nonQuoteAttachments.map((file, index) => {
- const msg = getMessageFromAttachment(file, translateLanguage);
-
- if (file.image_url) {
- return (
-
- );
- }
-
- if (file.audio_url) {
- return ;
- }
-
- if (file.video_url) {
- return ;
- }
-
- if (file.actions && file.actions.length > 0) {
- return ;
- }
- if (typeof file.collapsed === 'boolean') {
- return (
-
- );
- }
-
- if (file.attachments?.length) {
- return (
-
- );
- }
-
- return null;
- });
-
- return {attachmentsElements};
- },
- (prevProps, nextProps) => dequal(prevProps.attachments, nextProps.attachments)
-);
-
-Attachments.displayName = 'MessageAttachments';
-
-export default Attachments;
diff --git a/app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.stories.tsx b/app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.stories.tsx
deleted file mode 100644
index eb69fcc104e..00000000000
--- a/app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.stories.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { View } from 'react-native';
-
-import MessageContext from '../../../Context';
-import CollapsibleQuote from '.';
-
-const testAttachment = {
- ts: '1970-01-01T00:00:00.000Z',
- title: 'Engineering (9 today)',
- fields: [
- {
- title: 'Out Today:\n',
- value:
- 'Ricardo Mellu, 1 day, until Fri Mar 11\nLoma, 1 day, until Fri Mar 11\nAnitta, 3 hours\nDiego Carlitos, 19 days, until Fri Mar 11\nGabriel Vasconcelos, 5 days, until Fri Mar 11\nJorge Leite, 1 day, until Fri Mar 11\nKevin Aleman, 1 day, until Fri Mar 11\nPierre, 1 day, until Fri Mar 11\nTiago Evangelista Pinto, 1 day, until Fri Mar 11'
- }
- ],
- attachments: [],
- collapsed: true
-};
-
-export default {
- title: 'CollapsibleQuote'
-};
-
-export const Item = () => (
-
- {},
- user: { username: 'Marcos' }
- }}>
-
-
-
-);
diff --git a/app/containers/message/Components/Attachments/CollapsibleQuote/index.tsx b/app/containers/message/Components/Attachments/CollapsibleQuote/index.tsx
deleted file mode 100644
index 0b46a1cbf92..00000000000
--- a/app/containers/message/Components/Attachments/CollapsibleQuote/index.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { transparentize } from 'color2k';
-import { dequal } from 'dequal';
-import { useContext, useState, memo } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-
-import { themes } from '../../../../../lib/constants/colors';
-import { type IAttachment } from '../../../../../definitions/IAttachment';
-import { CustomIcon } from '../../../../CustomIcon';
-import { useTheme } from '../../../../../theme';
-import sharedStyles from '../../../../../views/Styles';
-import Markdown from '../../../../markdown';
-import MessageContext from '../../../Context';
-import Touchable from '../../../Touchable';
-import { BUTTON_HIT_SLOP } from '../../../utils';
-
-const styles = StyleSheet.create({
- button: {
- flexDirection: 'row',
- alignItems: 'center',
- borderWidth: 1,
- borderRadius: 4,
- minHeight: 40
- },
- attachmentContainer: {
- flex: 1,
- borderRadius: 4,
- padding: 8
- },
- authorContainer: {
- flexDirection: 'row'
- },
- fieldContainer: {
- flexDirection: 'column',
- paddingLeft: 10,
- paddingVertical: 10
- },
- fieldText: {
- fontSize: 15,
- padding: 10,
- ...sharedStyles.textRegular
- },
- fieldTitle: {
- fontSize: 15,
- ...sharedStyles.textBold
- },
- marginTop: {
- marginTop: 4
- },
- marginBottom: {
- marginBottom: 4
- },
- title: {
- fontSize: 16,
- ...sharedStyles.textMedium
- },
- touchableContainer: {
- flexDirection: 'row'
- },
- iconContainer: {
- width: 20,
- height: 20,
- right: 8,
- top: 8,
- justifyContent: 'center',
- alignItems: 'center'
- }
-});
-
-interface IMessageAttText {
- text?: string;
-}
-
-interface IMessageFields {
- attachment: IAttachment;
-}
-
-interface IMessageReply {
- attachment: IAttachment;
- timeFormat?: string;
-}
-
-const AttText = memo(
- ({ text }: IMessageAttText) => {
- 'use memo';
-
- const { user } = useContext(MessageContext);
-
- if (!text) {
- return null;
- }
-
- return ;
- },
- (prevProps, nextProps) => prevProps.text === nextProps.text
-);
-
-const Fields = memo(
- ({ attachment }: IMessageFields) => {
- 'use memo';
-
- const { theme } = useTheme();
- const { user } = useContext(MessageContext);
-
- if (!attachment.fields) {
- return null;
- }
-
- return (
- <>
- {attachment.fields.map(field => (
-
-
- {field.title}
-
-
-
- ))}
- >
- );
- },
- (prevProps, nextProps) => dequal(prevProps.attachment.fields, nextProps.attachment.fields)
-);
-
-const CollapsibleQuote = memo(
- ({ attachment }: IMessageReply) => {
- 'use memo';
-
- const { theme } = useTheme();
- const [collapsed, setCollapsed] = useState(attachment?.collapsed);
-
- if (!attachment) {
- return null;
- }
-
- const onPress = () => {
- setCollapsed(!collapsed);
- };
-
- let { strokeExtraLight, surfaceTint: backgroundColor, strokeLight, strokeMedium, fontSecondaryInfo } = themes[theme];
-
- try {
- if (attachment.color) {
- backgroundColor = transparentize(attachment.color, 0.8);
- strokeExtraLight = attachment.color;
- strokeLight = attachment.color;
- strokeMedium = attachment.color;
- fontSecondaryInfo = fontSecondaryInfo;
- }
- } catch (e) {
- // fallback to default
- }
-
- return (
- <>
-
-
-
-
- {attachment.title}
-
- {!collapsed && }
- {!collapsed && }
-
-
-
-
-
-
- >
- );
- },
- (prevProps, nextProps) => dequal(prevProps.attachment, nextProps.attachment)
-);
-
-CollapsibleQuote.displayName = 'CollapsibleQuote';
-Fields.displayName = 'CollapsibleQuoteFields';
-
-export default CollapsibleQuote;
diff --git a/app/containers/message/Components/Attachments/Quote.tsx b/app/containers/message/Components/Attachments/Quote.tsx
deleted file mode 100644
index eae787177cb..00000000000
--- a/app/containers/message/Components/Attachments/Quote.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import { useContext, memo, type FC } from 'react';
-import { dequal } from 'dequal';
-import { View } from 'react-native';
-
-import { Reply } from './components';
-import MessageContext from '../../Context';
-import { type IMessageAttachments } from '../../interfaces';
-import { type IAttachment } from '../../../../definitions';
-import { getMessageFromAttachment } from '../../utils';
-import { isQuoteAttachment } from './utils';
-
-const Quote: FC = memo(
- ({ attachments, timeFormat, showAttachment }: IMessageAttachments) => {
- 'use memo';
-
- const { translateLanguage } = useContext(MessageContext);
-
- const quotes = attachments?.filter(isQuoteAttachment);
-
- if (!quotes || !quotes?.length) {
- return null;
- }
-
- const quotesElements = quotes.map((file: IAttachment, index: number) => {
- const msg = getMessageFromAttachment(file, translateLanguage);
-
- return ;
- });
-
- return {quotesElements};
- },
- (prevProps, nextProps) => dequal(prevProps.attachments, nextProps.attachments)
-);
-
-Quote.displayName = 'MessageQuote';
-
-export default Quote;
diff --git a/app/containers/message/Components/Attachments/Reply.tsx b/app/containers/message/Components/Attachments/Reply.tsx
deleted file mode 100644
index 5bb9cd5f494..00000000000
--- a/app/containers/message/Components/Attachments/Reply.tsx
+++ /dev/null
@@ -1,276 +0,0 @@
-import { dequal } from 'dequal';
-import { useContext, useState, memo } from 'react';
-import { StyleSheet, Text, View } from 'react-native';
-import { Image } from 'expo-image';
-
-import { type IAttachment } from '../../../../definitions';
-import { themes } from '../../../../lib/constants/colors';
-import { fileDownloadAndPreview } from '../../../../lib/methods/helpers';
-import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl';
-import openLink from '../../../../lib/methods/helpers/openLink';
-import { type TSupportedThemes, useTheme } from '../../../../theme';
-import sharedStyles from '../../../../views/Styles';
-import RCActivityIndicator from '../../../ActivityIndicator';
-import Markdown, { MarkdownPreview } from '../../../markdown';
-import { Attachments } from './components';
-import Quote from './Quote';
-import MessageContext from '../../Context';
-import Touchable from '../../Touchable';
-import messageStyles from '../../styles';
-import dayjs from '../../../../lib/dayjs';
-
-const styles = StyleSheet.create({
- button: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- alignSelf: 'flex-start',
- borderLeftWidth: 2
- },
- attachmentContainer: {
- flex: 1,
- borderRadius: 4,
- flexDirection: 'row',
- paddingVertical: 4,
- paddingLeft: 8
- },
- backdrop: {
- ...StyleSheet.absoluteFillObject
- },
- authorContainer: {
- flex: 1,
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4
- },
- titleAndDescriptionContainer: {
- flexDirection: 'column',
- flex: 1,
- width: 200,
- gap: 4
- },
- author: {
- fontSize: 16,
- ...sharedStyles.textMedium,
- flexShrink: 1
- },
- fieldsContainer: {
- flex: 1,
- flexWrap: 'wrap',
- flexDirection: 'row',
- rowGap: 12
- },
- fieldContainer: {
- flexDirection: 'column'
- },
- fieldTitle: {
- fontSize: 14,
- ...sharedStyles.textSemibold
- },
- fieldValue: {
- fontSize: 14,
- ...sharedStyles.textRegular
- },
- marginTop: {
- marginTop: 4
- },
- marginBottom: {
- marginBottom: 4
- },
- image: {
- height: 80,
- width: 80,
- borderTopLeftRadius: 4,
- borderTopRightRadius: 4,
- marginBottom: 1,
- marginLeft: 20
- },
- title: {
- flex: 1,
- fontSize: 16,
- ...sharedStyles.textMedium
- }
-});
-
-interface IMessageReply {
- attachment: IAttachment;
- timeFormat?: string;
- msg?: string;
- showAttachment?: (file: IAttachment) => void;
-}
-
-const Title = memo(
- ({ attachment, timeFormat, theme }: { attachment: IAttachment; timeFormat?: string; theme: TSupportedThemes }) => {
- 'use memo';
-
- const time = attachment.message_link && attachment.ts ? dayjs(attachment.ts).format(timeFormat) : null;
- return (
-
- {attachment.author_name ? (
-
- {attachment.author_name}
-
- ) : null}
- {time ? {time} : null}
- {attachment.title ? {attachment.title} : null}
-
- );
- }
-);
-
-const Description = memo(
- ({ attachment }: { attachment: IAttachment }) => {
- 'use memo';
-
- const { user } = useContext(MessageContext);
- const text = attachment.text || attachment.title;
-
- if (!text) {
- return null;
- }
-
- // For file attachments without explicit text, the title is just a filename (e.g., "test.py").
- // We use MarkdownPreview to avoid markdown parsing treating filenames as URLs or markdown syntax.
- // For other attachments (message quotes, embeds), the text may contain actual markdown formatting,
- // so we use the full Markdown component to preserve styling.
- const isFileName = attachment.type === 'file' && !attachment.text;
-
- if (isFileName) {
- return ;
- }
-
- return ;
- },
- (prevProps, nextProps) => {
- if (prevProps.attachment.text !== nextProps.attachment.text) {
- return false;
- }
- if (prevProps.attachment.title !== nextProps.attachment.title) {
- return false;
- }
- if (prevProps.attachment.type !== nextProps.attachment.type) {
- return false;
- }
- return true;
- }
-);
-
-const UrlImage = memo(
- ({ image }: { image?: string }) => {
- 'use memo';
-
- const { baseUrl, user } = useContext(MessageContext);
-
- if (!image) {
- return null;
- }
-
- image = image.includes('http') ? image : `${baseUrl}/${image}?rc_uid=${user.id}&rc_token=${user.token}`;
- return ;
- },
- (prevProps, nextProps) => prevProps.image === nextProps.image
-);
-
-const Fields = memo(
- ({ attachment, theme }: { attachment: IAttachment; theme: TSupportedThemes }) => {
- 'use memo';
-
- const { user } = useContext(MessageContext);
-
- if (!attachment.fields) {
- return null;
- }
-
- return (
-
- {attachment.fields.map(field => (
-
- {field.title}
-
-
- ))}
-
- );
- },
- (prevProps, nextProps) =>
- dequal(prevProps.attachment.fields, nextProps.attachment.fields) && prevProps.theme === nextProps.theme
-);
-
-const Reply = memo(
- ({ attachment, timeFormat, msg, showAttachment }: IMessageReply) => {
- 'use memo';
-
- const [loading, setLoading] = useState(false);
- const { theme } = useTheme();
- const { baseUrl, user, id, e2e, isEncrypted } = useContext(MessageContext);
-
- if (!attachment || (isEncrypted && !e2e)) {
- return null;
- }
-
- const onPress = async () => {
- let url = attachment.title_link || attachment.author_link;
- if (!url) {
- return;
- }
- if (attachment.type === 'file' && attachment.title_link) {
- setLoading(true);
- url = formatAttachmentUrl(attachment.title_link, user.id, user.token, baseUrl);
- await fileDownloadAndPreview(url, attachment, id);
- setLoading(false);
- return;
- }
- openLink(url, theme);
- };
-
- let { strokeLight } = themes[theme];
- if (attachment.color) {
- strokeLight = attachment.color;
- }
-
- return (
-
-
-
-
-
-
-
-
-
- {loading ? (
-
-
-
-
- ) : null}
-
-
-
-
- {msg ? : null}
-
- );
- },
- (prevProps, nextProps) => dequal(prevProps.attachment, nextProps.attachment)
-);
-
-Reply.displayName = 'MessageReply';
-Title.displayName = 'MessageReplyTitle';
-Description.displayName = 'MessageReplyDescription';
-Fields.displayName = 'MessageReplyFields';
-
-export default Reply;
diff --git a/app/containers/message/Components/RightIcons/Encrypted.tsx b/app/containers/message/Components/RightIcons/Encrypted.tsx
deleted file mode 100644
index 449451b5dc7..00000000000
--- a/app/containers/message/Components/RightIcons/Encrypted.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { useContext, memo } from 'react';
-
-import Touchable from '../../Touchable';
-import { CustomIcon } from '../../../CustomIcon';
-import { BUTTON_HIT_SLOP } from '../../utils';
-import MessageContext from '../../Context';
-import styles from '../../styles';
-import { E2E_MESSAGE_TYPE } from '../../../../lib/constants/keys';
-
-const Encrypted = memo(({ type }: { type: string }) => {
- 'use memo';
-
- const { onEncryptedPress } = useContext(MessageContext);
-
- if (type !== E2E_MESSAGE_TYPE) {
- return null;
- }
-
- return (
-
-
-
- );
-});
-
-export default Encrypted;
diff --git a/app/containers/message/Components/RightIcons/MessageError.tsx b/app/containers/message/Components/RightIcons/MessageError.tsx
deleted file mode 100644
index 8c5fbd5a629..00000000000
--- a/app/containers/message/Components/RightIcons/MessageError.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { useContext, memo } from 'react';
-
-import Touchable from '../../Touchable';
-import { CustomIcon } from '../../../CustomIcon';
-import styles from '../../styles';
-import { BUTTON_HIT_SLOP } from '../../utils';
-import { themes } from '../../../../lib/constants/colors';
-import MessageContext from '../../Context';
-import { useTheme } from '../../../../theme';
-
-const MessageError = memo(
- ({ hasError }: { hasError: boolean }) => {
- 'use memo';
-
- const { theme } = useTheme();
- const { onErrorPress } = useContext(MessageContext);
-
- if (!hasError) {
- return null;
- }
-
- return (
-
-
-
- );
- },
- (prevProps, nextProps) => prevProps.hasError === nextProps.hasError
-);
-
-MessageError.displayName = 'MessageError';
-
-export default MessageError;
diff --git a/app/containers/message/Components/RightIcons/Pinned.tsx b/app/containers/message/Components/RightIcons/Pinned.tsx
deleted file mode 100644
index 61aaf44fa83..00000000000
--- a/app/containers/message/Components/RightIcons/Pinned.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { type ReactElement } from 'react';
-
-import { CustomIcon } from '../../../CustomIcon';
-import styles from '../../styles';
-
-const Pinned = ({ pinned, testID }: { pinned?: boolean; testID?: string }): ReactElement | null => {
- 'use memo';
-
- if (pinned) return ;
- return null;
-};
-
-export default Pinned;
diff --git a/app/containers/message/Components/RightIcons/index.tsx b/app/containers/message/Components/RightIcons/index.tsx
deleted file mode 100644
index 6504a981597..00000000000
--- a/app/containers/message/Components/RightIcons/index.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import { type ReactElement } from 'react';
-import { StyleSheet, View } from 'react-native';
-
-import { type MessageType } from '../../../../definitions';
-import Edited from './Edited';
-import Encrypted from './Encrypted';
-import MessageError from './MessageError';
-import Pinned from './Pinned';
-import ReadReceipt from './ReadReceipt';
-import Translated from './Translated';
-
-const styles = StyleSheet.create({
- actionIcons: {
- flexDirection: 'row'
- }
-});
-
-interface IRightIcons {
- type: MessageType;
- msg?: string;
- isEdited: boolean;
- isReadReceiptEnabled?: boolean;
- unread?: boolean;
- hasError: boolean;
- isTranslated: boolean;
- pinned?: boolean;
-}
-
-const RightIcons = ({
- type,
- msg,
- isEdited,
- hasError,
- isReadReceiptEnabled,
- unread,
- isTranslated,
- pinned
-}: IRightIcons): ReactElement => {
- 'use memo';
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-
-export default RightIcons;
diff --git a/app/containers/message/Content.tsx b/app/containers/message/Content.tsx
deleted file mode 100644
index 8e6ce6387be..00000000000
--- a/app/containers/message/Content.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-import { useContext, memo } from 'react';
-import { Text, View } from 'react-native';
-import { dequal } from 'dequal';
-
-import I18n from '../../i18n';
-import styles from './styles';
-import Markdown, { MarkdownPreview } from '../markdown';
-import User from './User';
-import { messageHaveAuthorName, getInfoMessage, getPreviewMessageFromAttachment } from './utils';
-import MessageContext from './Context';
-import { type IMessageContent } from './interfaces';
-import { useTheme } from '../../theme';
-import { themes } from '../../lib/constants/colors';
-import { type MessageTypesValues } from '../../definitions';
-
-const Content = memo(
- (props: IMessageContent) => {
- 'use memo';
-
- const { theme } = useTheme();
- const { user, onLinkPress } = useContext(MessageContext);
-
- if (props.isInfo) {
- // @ts-ignore
- const infoMessage = getInfoMessage({ ...props });
-
- const renderMessageContent = (
-
- {infoMessage}
-
- );
- if (messageHaveAuthorName(props.type as MessageTypesValues)) {
- return (
-
- {renderMessageContent}
-
- );
- }
-
- return renderMessageContent;
- }
-
- const isPreview = props.tmid && !props.isThreadRoom;
- let content = null;
-
- if (props.isEncrypted) {
- content = (
-
- {I18n.t('Encrypted_message')}
-
- );
- } else if (isPreview) {
- const previewMsg =
- props.msg ||
- (props.attachments?.length
- ? getPreviewMessageFromAttachment(props.attachments[0], props.autoTranslateLanguage)
- : undefined);
- content = previewMsg ? : null;
- } else if (props.msg) {
- content = (
-
- );
- }
-
- if (props.isIgnored) {
- content = (
-
- {I18n.t('Message_Ignored')}
-
- );
- }
-
- return content ? (
-
- {content}
-
- ) : null;
- },
- (prevProps, nextProps) => {
- if (prevProps.isTemp !== nextProps.isTemp) {
- return false;
- }
- if (prevProps.msg !== nextProps.msg) {
- return false;
- }
- if (prevProps.type !== nextProps.type) {
- return false;
- }
- if (prevProps.isEncrypted !== nextProps.isEncrypted) {
- return false;
- }
- if (prevProps.isIgnored !== nextProps.isIgnored) {
- return false;
- }
- if (prevProps.tmid !== nextProps.tmid) {
- return false;
- }
- if (prevProps.isThreadRoom !== nextProps.isThreadRoom) {
- return false;
- }
- if (prevProps.autoTranslateLanguage !== nextProps.autoTranslateLanguage) {
- return false;
- }
- if (!dequal(prevProps.md, nextProps.md)) {
- return false;
- }
- if (!dequal(prevProps.mentions, nextProps.mentions)) {
- return false;
- }
- if (!dequal(prevProps.channels, nextProps.channels)) {
- return false;
- }
- if (!dequal(prevProps.attachments, nextProps.attachments)) {
- return false;
- }
- return true;
- }
-);
-
-Content.displayName = 'MessageContent';
-
-export default Content;
diff --git a/app/containers/message/Context.ts b/app/containers/message/Context.ts
deleted file mode 100644
index 3f2784fce01..00000000000
--- a/app/containers/message/Context.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import { createContext } from 'react';
-// @ts-ignore
-const MessageContext = createContext();
-export default MessageContext;
diff --git a/app/containers/message/Discussion.tsx b/app/containers/message/Discussion.tsx
deleted file mode 100644
index 15ecc2830e5..00000000000
--- a/app/containers/message/Discussion.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { useContext, memo } from 'react';
-import { Text, View } from 'react-native';
-
-import Touchable from './Touchable';
-import { BUTTON_HIT_SLOP, formatMessageCount } from './utils';
-import styles from './styles';
-import I18n from '../../i18n';
-import { CustomIcon } from '../CustomIcon';
-import { DISCUSSION } from './constants';
-import MessageContext from './Context';
-import { formatDateThreads } from '../../lib/methods/helpers/room';
-import { type IMessage } from '../../definitions';
-import { useTheme } from '../../theme';
-
-// TODO: Create a reusable button component for message
-const Discussion = memo(
- ({ msg, dcount, dlm }: Pick) => {
- 'use memo';
-
- const { colors } = useTheme();
- let time;
- if (dlm) {
- time = formatDateThreads(dlm);
- }
- const buttonText = formatMessageCount(dcount, DISCUSSION);
- const { onDiscussionPress } = useContext(MessageContext);
- return (
-
- {I18n.t('Started_discussion')}
- {msg}
-
-
-
-
- {buttonText}
-
-
- {time}
-
-
- );
- },
- (prevProps, nextProps) => {
- if (prevProps.msg !== nextProps.msg) {
- return false;
- }
- if (prevProps.dcount !== nextProps.dcount) {
- return false;
- }
- if (prevProps.dlm !== nextProps.dlm) {
- return false;
- }
- return true;
- }
-);
-
-Discussion.displayName = 'MessageDiscussion';
-
-export default Discussion;
diff --git a/app/containers/message/Emoji.tsx b/app/containers/message/Emoji.tsx
deleted file mode 100644
index 68e880b39ec..00000000000
--- a/app/containers/message/Emoji.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { memo } from 'react';
-import { Text } from 'react-native';
-
-import useShortnameToUnicode from '../../lib/hooks/useShortnameToUnicode';
-import { useCustomEmoji } from '../../lib/hooks/useCustomEmoji';
-import CustomEmoji from '../EmojiPicker/CustomEmoji';
-import { type IMessageEmoji } from './interfaces';
-
-const Emoji = memo(
- ({ content, standardEmojiStyle, customEmojiStyle }: IMessageEmoji) => {
- 'use memo';
-
- const getCustomEmoji = useCustomEmoji();
- const parsedContent = content.replace(/^:|:$/g, '');
- const emoji = getCustomEmoji(parsedContent);
- const { formatShortnameToUnicode } = useShortnameToUnicode();
- if (emoji) {
- return ;
- }
- return {formatShortnameToUnicode(content)};
- },
- () => true
-);
-
-Emoji.displayName = 'MessageEmoji';
-
-export default Emoji;
diff --git a/app/containers/message/Message.test.tsx b/app/containers/message/Message.test.tsx
deleted file mode 100644
index a047bf7a768..00000000000
--- a/app/containers/message/Message.test.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { generateSnapshots } from '../../../.rnstorybook/generateSnapshots';
-import * as stories from './Message.stories';
-
-jest.mock('../../lib/methods/handleMediaDownload.ts', () => ({
- downloadMediaFile: jest.fn(),
- getMediaCache: jest.fn(),
- isDownloadActive: jest.fn()
-}));
-
-jest.unmock('../../lib/hooks/useResponsiveLayout/useResponsiveLayout');
-
-generateSnapshots(stories);
diff --git a/app/containers/message/Message.tsx b/app/containers/message/Message.tsx
deleted file mode 100644
index ec465eec8da..00000000000
--- a/app/containers/message/Message.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-import { useContext, memo } from 'react';
-import { View, type ViewStyle, type AccessibilityActionEvent, type AccessibilityActionInfo } from 'react-native';
-import { A11y } from 'react-native-a11y-order';
-
-import MessageContext from './Context';
-import User from './User';
-import styles from './styles';
-import RepliedThread from './RepliedThread';
-import MessageAvatar from './MessageAvatar';
-import Attachments from './Components/Attachments';
-import Urls from './Urls';
-import Thread from './Thread';
-import Blocks from './Blocks';
-import Reactions from './Reactions';
-import Broadcast from './Broadcast';
-import Discussion from './Discussion';
-import Content from './Content';
-import CallButton from './CallButton';
-import { type IMessage, type IMessageInner, type IMessageTouchable } from './interfaces';
-import { useTheme } from '../../theme';
-import RightIcons from './Components/RightIcons';
-import { WidthAwareView } from './Components/WidthAwareView';
-import MessageTime from './Time';
-import { useResponsiveLayout } from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
-import Quote from './Components/Attachments/Quote';
-import Touch from './Touch';
-import { useLastFocusedMessageRef } from '../../lib/a11y/useLastFocusedMessageRef';
-import { useMessageAccessibilityLabel } from './hooks/useMessageAccessibilityLabel';
-import { useMessageAccessibilityActions } from './hooks/useMessageAccessibilityActions';
-import { useMessageAccessibilityHint } from './hooks/useMessageAccessibilityHint';
-
-const MessageInner = memo((props: IMessageInner) => {
- const { isLargeFontScale } = useResponsiveLayout();
- const showTimeLarge = isLargeFontScale && props.isHeader;
-
- let content;
- if (props.isPreview) {
- content = (
- <>
-
- {showTimeLarge ? : null}
- <>
-
-
-
- >
-
- >
- );
- }
-
- if (props.type === 'discussion-created') {
- content = (
- <>
-
- {showTimeLarge ? : null}
-
- >
- );
- }
-
- if (props.type === 'jitsi_call_started') {
- content = (
- <>
-
-
-
- {showTimeLarge ? : null}
- >
- );
- }
-
- if (props.blocks && props.blocks.length) {
- content = (
- <>
-
-
-
-
- {showTimeLarge ? : null}
- >
- );
- }
-
- if (!content) {
- content = (
- <>
-
- {showTimeLarge ? : null}
-
-
-
-
-
-
-
-
-
- >
- );
- }
-
- return {content};
-});
-MessageInner.displayName = 'MessageInner';
-
-interface IMessageA11y {
- accessibilityActions?: AccessibilityActionInfo[];
- onAccessibilityAction?: (event: AccessibilityActionEvent) => void;
- handleLongPress?: () => void;
-}
-const Message = memo((props: IMessageTouchable & IMessage & IMessageA11y) => {
- if (props.isThreadReply || props.isThreadSequential || props.isInfo || props.isIgnored) {
- const thread = props.isThreadReply ? : null;
- // Prevent misalignment of info when the font size is increased.
- const infoStyle: ViewStyle = props.isInfo ? { alignItems: 'center' } : {};
- return (
-
- {thread}
-
-
-
-
-
- {props.isInfo && props.type === 'message_pinned' ? (
-
-
-
- ) : null}
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {!props.isHeader ? (
-
- ) : null}
-
-
-
- );
-});
-Message.displayName = 'Message';
-
-const MessageTouchable = memo((props: IMessageTouchable & IMessage) => {
- const { onPress, onLongPress } = useContext(MessageContext);
- const { colors } = useTheme();
- const { ref: touchRef, markAsLastFocused } = useLastFocusedMessageRef();
- const accessibilityLabelValue = useMessageAccessibilityLabel(props);
- const isDisabled =
- (props.isInfo && !props.isThreadReply) || props.archived || props.isTemp || props.type === 'jitsi_call_started';
- const accessibilityActions = useMessageAccessibilityActions(isDisabled);
- const accessibilityHint = useMessageAccessibilityHint(props);
-
- let backgroundColor = undefined;
- if (props.isBeingEdited) {
- backgroundColor = colors.statusBackgroundWarning2;
- }
- if (props.highlighted) {
- backgroundColor = colors.surfaceNeutral;
- }
-
- if (props.hasError || props.isInfo) {
- return (
-
-
-
- );
- }
-
- const handleLongPress = () => {
- markAsLastFocused();
- onLongPress();
- };
-
- return (
-
-
- {
- if (e.nativeEvent.actionName === 'showActions') handleLongPress();
- }}>
-
-
-
-
- );
-});
-
-MessageTouchable.displayName = 'MessageTouchable';
-
-export default MessageTouchable;
diff --git a/app/containers/message/Preview.tsx b/app/containers/message/Preview.tsx
deleted file mode 100644
index 5bcd6045994..00000000000
--- a/app/containers/message/Preview.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import Message from './index';
-import { useAppSelector } from '../../lib/hooks/useAppSelector';
-import { getUserSelector } from '../../selectors/login';
-import { type TAnyMessageModel } from '../../definitions';
-
-const MessagePreview = ({ message }: { message: TAnyMessageModel }) => {
- 'use memo';
-
- const { user, baseUrl, Message_TimeFormat, useRealName } = useAppSelector(state => ({
- user: getUserSelector(state),
- baseUrl: state.server.server,
- Message_TimeFormat: state.settings.Message_TimeFormat as string,
- useRealName: state.settings.UI_Use_Real_Name as boolean
- }));
-
- return (
-
- );
-};
-
-export default MessagePreview;
diff --git a/app/containers/message/Reactions.test.tsx b/app/containers/message/Reactions.test.tsx
deleted file mode 100644
index 5adeeaf39c3..00000000000
--- a/app/containers/message/Reactions.test.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { useState } from 'react';
-import { Provider } from 'react-redux';
-import { render, fireEvent } from '@testing-library/react-native';
-
-import Reactions from './Reactions';
-import MessageContext from './Context';
-import { setUser } from '../../actions/login';
-import { mockedStore } from '../../reducers/mockedStore';
-import { type IReaction } from '../../definitions';
-
-const initialMockedStoreState = () => {
- mockedStore.dispatch(
- setUser({
- settings: {
- preferences: {
- convertAsciiEmoji: true
- }
- }
- })
- );
-};
-
-initialMockedStoreState();
-
-const TestWrapper = ({ initialReactions }: { initialReactions: IReaction[] }) => {
- const [reactions, setReactions] = useState(initialReactions);
- const handleReactionPress = (emoji: string) => {
- setReactions(prev => prev.filter(r => r.emoji !== emoji));
- };
-
- return (
-
-
-
-
-
- );
-};
-
-it('renders all reactions and AddReaction button', () => {
- const reactions: IReaction[] = [
- { _id: '1', emoji: '👍', usernames: ['john', 'alice'], names: [] },
- { _id: '2', emoji: '😂', usernames: ['bob'], names: [] }
- ];
-
- const { getByTestId } = render();
-
- expect(getByTestId('message-reaction-👍')).toBeTruthy();
- expect(getByTestId('message-reaction-😂')).toBeTruthy();
- expect(getByTestId('message-add-reaction')).toBeTruthy();
-});
-
-it('should render unicode emoji reaction', () => {
- const reactions = [{ _id: '1', emoji: ':)', usernames: ['john', 'alice'], names: [] }];
- const { getByTestId } = render();
-
- expect(getByTestId('message-reaction-:)')).toBeTruthy();
- expect(getByTestId('message-add-reaction')).toBeTruthy();
-});
-
-it('should render custom emoji reaction', () => {
- const reactions = [{ _id: '1', emoji: ':aaaaa:', usernames: ['john', 'alice'], names: [] }];
- const { getByTestId } = render();
-
- expect(getByTestId('message-reaction-:aaaaa:')).toBeTruthy();
- expect(getByTestId('message-add-reaction')).toBeTruthy();
-});
-
-it('should remove reaction', () => {
- const reactions = [
- { _id: '1', emoji: ':thumbsup:', usernames: ['john'], names: [] },
- { _id: '1', emoji: ':heart_eyes:', usernames: ['john', 'alice'], names: [] }
- ];
- const { getByTestId, queryByTestId } = render();
-
- expect(getByTestId('message-reaction-:thumbsup:')).toBeTruthy();
- expect(getByTestId('message-reaction-:heart_eyes:')).toBeTruthy();
- fireEvent.press(getByTestId('message-reaction-:thumbsup:'));
- expect(queryByTestId('message-reaction-:thumbsup:')).toBeNull();
- expect(getByTestId('message-add-reaction')).toBeTruthy();
-});
diff --git a/app/containers/message/Reactions.tsx b/app/containers/message/Reactions.tsx
deleted file mode 100644
index 08013aabb49..00000000000
--- a/app/containers/message/Reactions.tsx
+++ /dev/null
@@ -1,105 +0,0 @@
-import { useContext, memo } from 'react';
-import { Text, useWindowDimensions, View } from 'react-native';
-
-import I18n from '../../i18n';
-import Touchable from './Touchable';
-import { CustomIcon } from '../CustomIcon';
-import styles from './styles';
-import Emoji from './Emoji';
-import { BUTTON_HIT_SLOP } from './utils';
-import { themes } from '../../lib/constants/colors';
-import { type TSupportedThemes, useTheme } from '../../theme';
-import MessageContext from './Context';
-
-interface IReaction {
- _id: string;
- emoji: string;
- usernames: string[];
-}
-
-interface IMessageReaction {
- reaction: IReaction;
- theme: TSupportedThemes;
-}
-
-interface IMessageReactions {
- reactions?: IReaction[];
-}
-
-const AddReaction = memo(({ theme }: { theme: TSupportedThemes }) => {
- 'use memo';
-
- const { reactionInit } = useContext(MessageContext);
- const { fontScale } = useWindowDimensions();
- const height = 28 * fontScale;
- return (
-
-
-
-
-
- );
-});
-
-const Reaction = memo(({ reaction, theme }: IMessageReaction) => {
- 'use memo';
-
- const { onReactionPress, onReactionLongPress, user } = useContext(MessageContext);
- const { fontScale } = useWindowDimensions();
- const height = 28 * fontScale;
- const reacted = reaction.usernames.findIndex((item: string) => item === user.username) !== -1;
- return (
- onReactionPress(reaction.emoji)}
- onLongPress={onReactionLongPress}
- key={reaction.emoji}
- testID={`message-reaction-${reaction.emoji}`}
- accessibilityRole='button'
- accessibilityLabel={`${reaction.emoji}, ${reaction.usernames.length}`}
- accessibilityState={{ selected: reacted }}
- style={[styles.reactionButton, { backgroundColor: reacted ? themes[theme].surfaceNeutral : themes[theme].surfaceRoom }]}
- hitSlop={BUTTON_HIT_SLOP}
- android_ripple={{ color: themes[theme].strokeLight }}>
-
-
- {reaction.usernames.length}
-
-
- );
-});
-
-const Reactions = memo(({ reactions }: IMessageReactions) => {
- 'use memo';
-
- const { theme } = useTheme();
-
- if (!Array.isArray(reactions) || reactions.length === 0) {
- return null;
- }
- return (
-
- {reactions.map(reaction => (
-
- ))}
-
-
- );
-});
-
-Reaction.displayName = 'MessageReaction';
-Reactions.displayName = 'MessageReactions';
-AddReaction.displayName = 'MessageAddReaction';
-
-export default Reactions;
diff --git a/app/containers/message/RepliedThread.tsx b/app/containers/message/RepliedThread.tsx
deleted file mode 100644
index 4f398297dc4..00000000000
--- a/app/containers/message/RepliedThread.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { memo, useEffect, useState } from 'react';
-import { View } from 'react-native';
-
-import { CustomIcon } from '../CustomIcon';
-import styles from './styles';
-import I18n from '../../i18n';
-import { MarkdownPreview } from '../markdown';
-import { type IMessageRepliedThread } from './interfaces';
-import { useTheme } from '../../theme';
-import { AvatarContainer } from './MessageAvatar';
-
-const RepliedThread = memo(({ tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted }: IMessageRepliedThread) => {
- 'use memo';
-
- const { colors } = useTheme();
- const [msg, setMsg] = useState(isEncrypted ? I18n.t('Encrypted_message') : tmsg);
-
- useEffect(() => {
- if (!msg) {
- fetch();
- }
- }, []);
-
- if (!tmid || !isHeader) {
- return null;
- }
-
- const fetch = async () => {
- const threadName = fetchThreadName ? await fetchThreadName(tmid, id) : '';
- setMsg(threadName);
- };
-
- if (!msg) {
- return null;
- }
-
- return (
-
-
-
-
-
-
-
-
-
- );
-});
-
-RepliedThread.displayName = 'MessageRepliedThread';
-
-export default RepliedThread;
diff --git a/app/containers/message/Thread.tsx b/app/containers/message/Thread.tsx
deleted file mode 100644
index e1ebf3cdfb0..00000000000
--- a/app/containers/message/Thread.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import { useContext, memo } from 'react';
-import { Text, View } from 'react-native';
-
-import styles from './styles';
-import MessageContext from './Context';
-import ThreadDetails from '../ThreadDetails';
-import I18n from '../../i18n';
-import { type IMessageThread } from './interfaces';
-import { useTheme } from '../../theme';
-import Touchable from './Touchable';
-
-const Thread = memo(
- ({ msg, tcount, tlm, isThreadRoom, id }: IMessageThread) => {
- 'use memo';
-
- const { theme, colors } = useTheme();
- const { threadBadgeColor, toggleFollowThread, user, replies, onThreadPress } = useContext(MessageContext);
-
- const backgroundColor = threadBadgeColor ? colors.badgeBackgroundLevel2 : colors.buttonBackgroundSecondaryDefault;
- const textColor = threadBadgeColor || theme !== 'light' ? colors.fontWhite : colors.fontPureBlack;
-
- if (!tlm || isThreadRoom || tcount === null) {
- return null;
- }
-
- return (
-
-
- {I18n.t('View_Thread')}
-
-
-
- );
- },
- (prevProps, nextProps) => {
- if (prevProps.tcount !== nextProps.tcount) {
- return false;
- }
- return true;
- }
-);
-
-Thread.displayName = 'MessageThread';
-
-export default Thread;
diff --git a/app/containers/message/Time.tsx b/app/containers/message/Time.tsx
deleted file mode 100644
index 73256665efb..00000000000
--- a/app/containers/message/Time.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { Text, type TextStyle } from 'react-native';
-
-import dayjs from '../../lib/dayjs';
-import { useTheme } from '../../theme';
-import messageStyles from './styles';
-
-interface IMessageTime {
- ts?: Date;
- timeFormat?: string;
- style?: TextStyle;
-}
-
-const MessageTime = ({ timeFormat, ts, style }: IMessageTime) => {
- 'use memo';
-
- const { colors } = useTheme();
-
- const time = dayjs(ts).format(timeFormat);
-
- return {time};
-};
-
-export default MessageTime;
diff --git a/app/containers/message/Touchable.tsx b/app/containers/message/Touchable.tsx
deleted file mode 100644
index ad53493b8be..00000000000
--- a/app/containers/message/Touchable.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { useContext, memo, type ReactNode, type FC } from 'react';
-import { Pressable, type PressableProps } from 'react-native';
-
-import MessageContext from './Context';
-
-interface IProps extends PressableProps {
- children: ReactNode;
- onLongPress?: () => void;
-}
-
-const RCTouchable: FC = memo(({ children, ...props }) => {
- 'use memo';
-
- const { onLongPress } = useContext(MessageContext);
-
- return (
-
- {children}
-
- );
-});
-
-export default RCTouchable;
diff --git a/app/containers/message/User.tsx b/app/containers/message/User.tsx
deleted file mode 100644
index bcb80f3f645..00000000000
--- a/app/containers/message/User.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useContext, memo } from 'react';
-import { Pressable, StyleSheet, Text, View } from 'react-native';
-
-import { type MessageType, type MessageTypesValues, SubscriptionType } from '../../definitions';
-import { useTheme } from '../../theme';
-import { type IRoomInfoParam } from '../../views/SearchMessagesView';
-import sharedStyles from '../../views/Styles';
-import RightIcons from './Components/RightIcons';
-import MessageContext from './Context';
-import { messageHaveAuthorName } from './utils';
-import MessageTime from './Time';
-import { useResponsiveLayout } from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
-
-const styles = StyleSheet.create({
- container: {
- flex: 1,
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center'
- },
- actionIcons: {
- flexDirection: 'row',
- alignItems: 'center'
- },
- username: {
- flexShrink: 1,
- fontSize: 16,
- lineHeight: 22,
- ...sharedStyles.textSemibold
- },
- usernameInfoMessage: {
- fontSize: 16,
- ...sharedStyles.textMedium
- },
- titleContainer: {
- flexShrink: 1,
- flexDirection: 'row',
- alignItems: 'center',
- gap: 4
- },
- alias: {
- fontSize: 14,
- ...sharedStyles.textRegular
- }
-});
-
-interface IMessageUser {
- isHeader?: boolean;
- hasError: boolean;
- useRealName?: boolean;
- author?: {
- _id: string;
- name?: string;
- username?: string;
- };
- alias?: string;
- ts?: Date;
- timeFormat?: string;
- navToRoomInfo?: (navParam: IRoomInfoParam) => void;
- type: MessageType;
- isEdited: boolean;
- isReadReceiptEnabled?: boolean;
- unread?: boolean;
- pinned?: boolean;
- isTranslated: boolean;
-}
-
-const User = memo(
- ({
- isHeader,
- useRealName,
- author,
- alias,
- ts,
- timeFormat,
- hasError,
- navToRoomInfo,
- type,
- isEdited,
- isTranslated,
- ...props
- }: IMessageUser) => {
- 'use memo';
-
- const { user } = useContext(MessageContext);
- const { colors } = useTheme();
- const { isLargeFontScale } = useResponsiveLayout();
-
- if (isHeader) {
- const username = (useRealName && author?.name) || author?.username;
- const aliasUsername = alias ? @{username} : null;
- const itsMe = author?._id === user.id;
-
- const onUserPress = () => {
- navToRoomInfo?.({
- t: SubscriptionType.DIRECT,
- rid: author?._id || '',
- itsMe
- });
- };
-
- const textContent = (
- <>
- {alias || username}
- {aliasUsername}
- >
- );
-
- if (messageHaveAuthorName(type as MessageTypesValues)) {
- return (
-
- {textContent}
-
- );
- }
-
- return (
-
-
-
- {textContent}
-
- {isLargeFontScale ? null : }
-
-
-
- );
- }
- return null;
- }
-);
-
-User.displayName = 'MessageUser';
-
-export default User;
diff --git a/app/containers/message/__tests__/index.test.tsx b/app/containers/message/__tests__/index.test.tsx
new file mode 100644
index 00000000000..e46bab4f5be
--- /dev/null
+++ b/app/containers/message/__tests__/index.test.tsx
@@ -0,0 +1,123 @@
+import { act } from 'react';
+import { fireEvent } from '@testing-library/react-native';
+
+import MessageContainer from '../index';
+import { type TAnyMessageModel } from '../../../definitions';
+import { createMessageActionStore, MessageActionStoreContext } from '../stores/MessageActionStore';
+import { renderWithMessageProviders } from './testHelpers';
+import { type MessageRoomState } from '../stores/MessageRoomStore';
+
+jest.mock('../components/Touchable/Touch', () => {
+ const { forwardRef } = require('react');
+ const { TouchableOpacity } = require('react-native');
+ return forwardRef(({ children, onPress, onLongPress, ...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' },
+ alias: undefined,
+ groupable: true,
+ avatar: undefined,
+ emoji: undefined,
+ attachments: undefined,
+ urls: undefined,
+ status: undefined,
+ pinned: undefined,
+ editedBy: undefined,
+ reactions: undefined,
+ role: undefined,
+ drid: undefined,
+ dcount: undefined,
+ dlm: undefined,
+ tmid: undefined,
+ tcount: undefined,
+ tlm: undefined,
+ replies: undefined,
+ mentions: undefined,
+ channels: undefined,
+ unread: undefined,
+ autoTranslate: undefined,
+ translations: undefined,
+ tmsg: undefined,
+ blocks: undefined,
+ e2e: undefined,
+ md: undefined,
+ comment: undefined,
+ experimentalSubscribe: jest.fn(() => jest.fn()),
+ ...overrides
+ } as unknown as TAnyMessageModel);
+
+const baseProps = {
+ rid: 'room-1'
+};
+
+const room: Partial = { rid: baseProps.rid };
+
+const renderContainer = (itemOverrides: Record = {}, propOverrides: Record = {}) => {
+ const props = { ...baseProps, ...propOverrides };
+ return renderWithMessageProviders(, {
+ room
+ });
+};
+
+it('renders a normal message without crashing', () => {
+ const { getByTestId } = renderContainer();
+ expect(getByTestId('message-msg-1')).toBeTruthy();
+});
+
+it('renders the message text', () => {
+ const { getByText } = renderContainer({ msg: 'Hello World' });
+ expect(getByText('Hello World')).toBeTruthy();
+});
+
+it('reveals an ignored message when tapped', () => {
+ const { getByTestId, queryByTestId } = renderContainer({ msg: 'Hello World' }, { isIgnored: true });
+ expect(getByTestId('message-ignored-Hello World')).toBeTruthy();
+ fireEvent.press(getByTestId('message-ignored-Hello World'));
+ expect(queryByTestId('message-ignored-Hello World')).toBeNull();
+});
+
+it('fires the onLongPress callback when long-pressed', () => {
+ const onLongPress = jest.fn();
+ const { getByTestId } = renderContainer({}, { onLongPress });
+ fireEvent(getByTestId('message-msg-1'), 'longPress');
+ expect(onLongPress).toHaveBeenCalledTimes(1);
+});
+
+describe('edit highlight reacts to MessageActionStore', () => {
+ it('shows editing testID when the store marks this message as being edited', () => {
+ const store = createMessageActionStore({ kind: 'edit', messageId: 'msg-1' });
+ const item = createMockMessage({ id: 'msg-1' });
+ const { getByTestId } = renderWithMessageProviders(
+
+
+ ,
+ { room }
+ );
+ expect(getByTestId('message-editing-msg-1')).toBeTruthy();
+ });
+
+ it('removes editing testID when the store clears', async () => {
+ const store = createMessageActionStore({ kind: 'edit', messageId: 'msg-1' });
+ const item = createMockMessage({ id: 'msg-1' });
+ const { queryByTestId } = renderWithMessageProviders(
+
+
+ ,
+ { room }
+ );
+ await act(() => {
+ store.getState().actions.clear();
+ });
+ expect(queryByTestId('message-editing-msg-1')).toBeNull();
+ });
+});
diff --git a/app/containers/message/__tests__/testHelpers.tsx b/app/containers/message/__tests__/testHelpers.tsx
new file mode 100644
index 00000000000..6775a41b771
--- /dev/null
+++ b/app/containers/message/__tests__/testHelpers.tsx
@@ -0,0 +1,42 @@
+import { type ReactElement, type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+import { render } from '@testing-library/react-native';
+
+import { type TAnyMessageModel } from '../../../definitions';
+import { mockedStore } from '../../../reducers/mockedStore';
+import { MessageProvider } from '../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../stores/MessageRoomStore';
+
+interface IMessageProvidersOptions {
+ item?: TAnyMessageModel;
+ previousItem?: TAnyMessageModel;
+ room?: Partial;
+ withRedux?: boolean;
+}
+
+export const MessageProviders = ({
+ item,
+ previousItem,
+ room,
+ withRedux = true,
+ children
+}: IMessageProvidersOptions & { children: ReactNode }): ReactElement => {
+ const roomState = room ?? {};
+
+ let tree: ReactNode = children;
+ if (item) {
+ tree = (
+
+ {tree}
+
+ );
+ }
+ tree = {tree};
+ if (withRedux) {
+ tree = {tree};
+ }
+ return tree as ReactElement;
+};
+
+export const renderWithMessageProviders = (ui: ReactElement, options: IMessageProvidersOptions = {}) =>
+ render({ui});
diff --git a/app/containers/message/utils.test.ts b/app/containers/message/__tests__/utils.test.ts
similarity index 95%
rename from app/containers/message/utils.test.ts
rename to app/containers/message/__tests__/utils.test.ts
index 186d55988d9..001f5609279 100644
--- a/app/containers/message/utils.test.ts
+++ b/app/containers/message/__tests__/utils.test.ts
@@ -1,4 +1,4 @@
-import { getPreviewMessageFromAttachment } from './utils';
+import { getPreviewMessageFromAttachment } from '../utils';
describe('getPreviewMessageFromAttachment', () => {
test('returns the Attachment title when only title is set', () => {
diff --git a/app/containers/message/Components/Attachments/AttachedActions.tsx b/app/containers/message/components/Attachments/AttachedActions.tsx
similarity index 82%
rename from app/containers/message/Components/Attachments/AttachedActions.tsx
rename to app/containers/message/components/Attachments/AttachedActions.tsx
index 3faea7c5e6f..0e6a6a6340b 100644
--- a/app/containers/message/Components/Attachments/AttachedActions.tsx
+++ b/app/containers/message/components/Attachments/AttachedActions.tsx
@@ -1,7 +1,5 @@
-import { useContext } from 'react';
-
import Button from '../../../Button';
-import MessageContext from '../../Context';
+import { useOnAnswerButtonPress } from '../../stores/MessageRoomStore';
import { type IAttachment } from '../../../../definitions';
import openLink from '../../../../lib/methods/helpers/openLink';
import Markdown from '../../../markdown';
@@ -16,7 +14,7 @@ export type TElement = {
const AttachedActions = ({ attachment }: { attachment: IAttachment }) => {
'use memo';
- const { onAnswerButtonPress } = useContext(MessageContext);
+ const onAnswerButtonPress = useOnAnswerButtonPress();
if (!attachment.actions) {
return null;
@@ -25,7 +23,7 @@ const AttachedActions = ({ attachment }: { attachment: IAttachment }) => {
const attachedButtons = attachment.actions.map((element: TElement) => {
const onPress = () => {
if (element.msg) {
- onAnswerButtonPress(element.msg);
+ onAnswerButtonPress?.(element.msg, false);
}
if (element.url) {
diff --git a/app/containers/message/components/Attachments/Attachments.tsx b/app/containers/message/components/Attachments/Attachments.tsx
new file mode 100644
index 00000000000..0eb95137c5d
--- /dev/null
+++ b/app/containers/message/components/Attachments/Attachments.tsx
@@ -0,0 +1,71 @@
+import { type FC } from 'react';
+import { View } from 'react-native';
+
+import Image from './Image';
+import Audio from './Audio';
+import Video from './Video';
+import CollapsibleQuote from './CollapsibleQuote';
+import AttachedActions from './AttachedActions';
+import Reply from './Reply';
+import { useShowAttachment } from '../../stores/MessageRoomStore';
+import { useMessageField, useTranslateLanguage } from '../../stores/MessageStore';
+import { type IMessageAttachments } from '../../interfaces';
+import { getMessageFromAttachment } from '../../utils';
+import { getAttachmentKey, isContentAttachment } from './utils';
+
+const Attachments: FC = ({ attachments }: IMessageAttachments) => {
+ 'use memo';
+
+ const translateLanguage = useTranslateLanguage();
+ const showAttachment = useShowAttachment();
+ const author = useMessageField(item => item.u);
+
+ const nonQuoteAttachments = attachments?.filter(isContentAttachment);
+
+ if (!nonQuoteAttachments || nonQuoteAttachments.length === 0) {
+ return null;
+ }
+
+ const attachmentsElements = nonQuoteAttachments.map((file, index) => {
+ const msg = getMessageFromAttachment(file, translateLanguage);
+
+ if (file.image_url) {
+ return (
+
+ );
+ }
+
+ if (file.audio_url) {
+ return ;
+ }
+
+ if (file.video_url) {
+ return ;
+ }
+
+ if (file.actions && file.actions.length > 0) {
+ return ;
+ }
+ if (typeof file.collapsed === 'boolean') {
+ return ;
+ }
+
+ if (file.attachments?.length) {
+ return ;
+ }
+
+ return null;
+ });
+
+ return {attachmentsElements};
+};
+
+export default Attachments;
diff --git a/app/containers/message/Components/Attachments/Audio.tsx b/app/containers/message/components/Attachments/Audio.tsx
similarity index 67%
rename from app/containers/message/Components/Attachments/Audio.tsx
rename to app/containers/message/components/Attachments/Audio.tsx
index d6879209af5..c0ed08d29aa 100644
--- a/app/containers/message/Components/Attachments/Audio.tsx
+++ b/app/containers/message/components/Attachments/Audio.tsx
@@ -1,11 +1,11 @@
-import { useContext } from 'react';
import { View } from 'react-native';
import { type IAttachment, type IUserMessage } from '../../../../definitions';
import AudioPlayer from '../../../AudioPlayer';
import Markdown from '../../../markdown';
-import MessageContext from '../../Context';
import { useMediaAutoDownload } from '../../hooks/useMediaAutoDownload';
+import { useMessageUser, useRid } from '../../stores/MessageRoomStore';
+import { useMessageId } from '../../stores/MessageStore';
interface IMessageAudioProps {
file: IAttachment;
@@ -16,13 +16,15 @@ interface IMessageAudioProps {
const MessageAudio = ({ file, author, msg }: IMessageAudioProps) => {
'use memo';
- const { user, id, rid } = useContext(MessageContext);
+ const user = useMessageUser();
+ const id = useMessageId();
+ const rid = useRid();
const { status, onPress, url } = useMediaAutoDownload({ file, author });
return (
- {msg ? : null}
-
+ {msg ? : null}
+
);
};
diff --git a/app/containers/message/components/Attachments/CollapsibleQuote.tsx b/app/containers/message/components/Attachments/CollapsibleQuote.tsx
new file mode 100644
index 00000000000..3bc665222c2
--- /dev/null
+++ b/app/containers/message/components/Attachments/CollapsibleQuote.tsx
@@ -0,0 +1,167 @@
+import { transparentize } from 'color2k';
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+
+import { type IAttachment } from '../../../../definitions/IAttachment';
+import { CustomIcon } from '../../../CustomIcon';
+import { useTheme } from '../../../../theme';
+import sharedStyles from '../../../../views/Styles';
+import Markdown from '../../../markdown';
+import { useMessageUser } from '../../stores/MessageRoomStore';
+import MessageActionTouchable from '../Touchable/MessageActionTouchable';
+import { BUTTON_HIT_SLOP } from '../../utils';
+
+const styles = StyleSheet.create({
+ button: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderWidth: 1,
+ borderRadius: 4,
+ minHeight: 40
+ },
+ attachmentContainer: {
+ flex: 1,
+ borderRadius: 4,
+ padding: 8
+ },
+ authorContainer: {
+ flexDirection: 'row'
+ },
+ fieldContainer: {
+ flexDirection: 'column',
+ paddingLeft: 10,
+ paddingVertical: 10
+ },
+ fieldTitle: {
+ fontSize: 15,
+ ...sharedStyles.textBold
+ },
+ marginBottom: {
+ marginBottom: 4
+ },
+ title: {
+ fontSize: 16,
+ ...sharedStyles.textMedium
+ },
+ touchableContainer: {
+ flexDirection: 'row'
+ },
+ iconContainer: {
+ width: 20,
+ height: 20,
+ right: 8,
+ top: 8,
+ justifyContent: 'center',
+ alignItems: 'center'
+ }
+});
+
+interface IMessageAttText {
+ text?: string;
+}
+
+interface IMessageFields {
+ attachment: IAttachment;
+}
+
+interface IMessageReply {
+ attachment: IAttachment;
+}
+
+const AttText = ({ text }: IMessageAttText) => {
+ 'use memo';
+
+ const user = useMessageUser();
+
+ if (!text) {
+ return null;
+ }
+
+ return ;
+};
+
+const Fields = ({ attachment }: IMessageFields) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const user = useMessageUser();
+
+ if (!attachment.fields) {
+ return null;
+ }
+
+ return (
+ <>
+ {attachment.fields.map(field => (
+
+
+ {field.title}
+
+
+
+ ))}
+ >
+ );
+};
+
+const CollapsibleQuote = ({ attachment }: IMessageReply) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const [collapsed, setCollapsed] = useState(attachment?.collapsed);
+
+ if (!attachment) {
+ return null;
+ }
+
+ const onPress = () => {
+ setCollapsed(!collapsed);
+ };
+
+ let { strokeExtraLight, surfaceTint: backgroundColor, strokeLight, strokeMedium } = colors;
+
+ try {
+ if (attachment.color) {
+ backgroundColor = transparentize(attachment.color, 0.8);
+ strokeExtraLight = attachment.color;
+ strokeLight = attachment.color;
+ strokeMedium = attachment.color;
+ }
+ } catch (e) {
+ // fallback to default
+ }
+
+ return (
+
+
+
+
+ {attachment.title}
+
+ {!collapsed && }
+ {!collapsed && }
+
+
+
+
+
+
+ );
+};
+
+export default CollapsibleQuote;
diff --git a/app/containers/message/Components/Attachments/Image/Button.tsx b/app/containers/message/components/Attachments/Image/Button.tsx
similarity index 83%
rename from app/containers/message/Components/Attachments/Image/Button.tsx
rename to app/containers/message/components/Attachments/Image/Button.tsx
index 9f56c024321..9304df94156 100644
--- a/app/containers/message/Components/Attachments/Image/Button.tsx
+++ b/app/containers/message/components/Attachments/Image/Button.tsx
@@ -1,7 +1,7 @@
import { type ReactElement } from 'react';
import { type PressableProps } from 'react-native';
-import Touchable from '../../../Touchable';
+import MessageActionTouchable from '../../Touchable/MessageActionTouchable';
import styles from '../../../styles';
interface IMessageButton {
@@ -22,13 +22,13 @@ export const Button = ({
'use memo';
return (
-
{children}
-
+
);
};
diff --git a/app/containers/message/Components/Attachments/Image/Container.tsx b/app/containers/message/components/Attachments/Image/Container.tsx
similarity index 87%
rename from app/containers/message/Components/Attachments/Image/Container.tsx
rename to app/containers/message/components/Attachments/Image/Container.tsx
index cd2c78f219a..e9768c34752 100644
--- a/app/containers/message/Components/Attachments/Image/Container.tsx
+++ b/app/containers/message/components/Attachments/Image/Container.tsx
@@ -1,4 +1,4 @@
-import { useContext, type ReactElement } from 'react';
+import { type ReactElement } from 'react';
import { View } from 'react-native';
import Markdown from '../../../../markdown';
@@ -7,7 +7,7 @@ import { Button } from './Button';
import { MessageImage } from './Image';
import AltTextLabel from '../../../../AltTextLabel';
import { type IImageContainer } from './definitions';
-import MessageContext from '../../../Context';
+import { useMessageUser } from '../../../stores/MessageRoomStore';
import { WidthAwareView } from '../../WidthAwareView';
import { useAltTextSupported } from '../../../../../lib/hooks/useAltTextSupported';
import I18n from '../../../../../i18n';
@@ -15,7 +15,7 @@ import I18n from '../../../../../i18n';
const ImageContainer = ({ file, showAttachment, author, msg, imagePreview, imageType }: IImageContainer): ReactElement | null => {
'use memo';
- const { user } = useContext(MessageContext);
+ const user = useMessageUser();
const { status, onPress, url, isEncrypted } = useMediaAutoDownload({ file, author, showAttachment });
const isAltTextSupported = useAltTextSupported();
const altText = file.altText || (isAltTextSupported ? msg : undefined);
@@ -44,7 +44,7 @@ const ImageContainer = ({ file, showAttachment, author, msg, imagePreview, image
if (msg) {
return (
-
+
{image}
);
@@ -53,6 +53,4 @@ const ImageContainer = ({ file, showAttachment, author, msg, imagePreview, image
return image;
};
-ImageContainer.displayName = 'MessageImageContainer';
-
export default ImageContainer;
diff --git a/app/containers/message/Components/Attachments/Image/Image.tsx b/app/containers/message/components/Attachments/Image/Image.tsx
similarity index 93%
rename from app/containers/message/Components/Attachments/Image/Image.tsx
rename to app/containers/message/components/Attachments/Image/Image.tsx
index 2a76e527f1c..906ebe13b4d 100644
--- a/app/containers/message/Components/Attachments/Image/Image.tsx
+++ b/app/containers/message/components/Attachments/Image/Image.tsx
@@ -1,4 +1,4 @@
-import { useContext, useEffect, useState, memo } from 'react';
+import { useContext, useEffect, useState } from 'react';
import { View, type ViewStyle } from 'react-native';
import { Image } from 'expo-image';
@@ -13,7 +13,7 @@ import { AUTOPLAY_GIFS_PREFERENCES_KEY } from '../../../../../lib/constants/keys
import ImageBadge from './ImageBadge';
import log from '../../../../../lib/methods/helpers/log';
-export const MessageImage = memo(({ uri, status, encrypted = false, imagePreview, imageType }: IMessageImage) => {
+export const MessageImage = ({ uri, status, encrypted = false, imagePreview, imageType }: IMessageImage) => {
'use memo';
const { colors } = useTheme();
@@ -97,6 +97,4 @@ export const MessageImage = memo(({ uri, status, encrypted = false, imagePreview
{isGif ? : null}
>
);
-});
-
-MessageImage.displayName = 'MessageImage';
+};
diff --git a/app/containers/message/Components/Attachments/Image/ImageBadge.tsx b/app/containers/message/components/Attachments/Image/ImageBadge.tsx
similarity index 100%
rename from app/containers/message/Components/Attachments/Image/ImageBadge.tsx
rename to app/containers/message/components/Attachments/Image/ImageBadge.tsx
diff --git a/app/containers/message/Components/Attachments/Image/definitions.ts b/app/containers/message/components/Attachments/Image/definitions.ts
similarity index 100%
rename from app/containers/message/Components/Attachments/Image/definitions.ts
rename to app/containers/message/components/Attachments/Image/definitions.ts
diff --git a/app/containers/message/Components/Attachments/Image/index.tsx b/app/containers/message/components/Attachments/Image/index.tsx
similarity index 100%
rename from app/containers/message/Components/Attachments/Image/index.tsx
rename to app/containers/message/components/Attachments/Image/index.tsx
diff --git a/app/containers/message/components/Attachments/Quote.tsx b/app/containers/message/components/Attachments/Quote.tsx
new file mode 100644
index 00000000000..65e3d2df941
--- /dev/null
+++ b/app/containers/message/components/Attachments/Quote.tsx
@@ -0,0 +1,31 @@
+import { type FC } from 'react';
+import { View } from 'react-native';
+
+import { Reply } from './components';
+import { type IMessageAttachments } from '../../interfaces';
+import { type IAttachment } from '../../../../definitions';
+import { getMessageFromAttachment } from '../../utils';
+import { getAttachmentKey, isQuoteAttachment } from './utils';
+import { useTranslateLanguage } from '../../stores/MessageStore';
+
+const Quote: FC = ({ attachments }: IMessageAttachments) => {
+ 'use memo';
+
+ const translateLanguage = useTranslateLanguage();
+
+ const quotes = attachments?.filter(isQuoteAttachment);
+
+ if (!quotes || !quotes?.length) {
+ return null;
+ }
+
+ const quotesElements = quotes.map((file: IAttachment, index: number) => {
+ const msg = getMessageFromAttachment(file, translateLanguage);
+
+ return ;
+ });
+
+ return {quotesElements};
+};
+
+export default Quote;
diff --git a/app/containers/message/components/Attachments/Reply.tsx b/app/containers/message/components/Attachments/Reply.tsx
new file mode 100644
index 00000000000..e35ff7ca980
--- /dev/null
+++ b/app/containers/message/components/Attachments/Reply.tsx
@@ -0,0 +1,239 @@
+import { useState } from 'react';
+import { StyleSheet, Text, View } from 'react-native';
+import { Image } from 'expo-image';
+
+import { type IAttachment } from '../../../../definitions';
+import { fileDownloadAndPreview } from '../../../../lib/methods/helpers';
+import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl';
+import openLink from '../../../../lib/methods/helpers/openLink';
+import { useTheme } from '../../../../theme';
+import sharedStyles from '../../../../views/Styles';
+import RCActivityIndicator from '../../../ActivityIndicator';
+import Markdown, { MarkdownPreview } from '../../../markdown';
+import { Attachments } from './components';
+import Quote from './Quote';
+import { useBaseUrl, useMessageUser, useTimeFormat } from '../../stores/MessageRoomStore';
+import { useIsEncrypted, useMessageId } from '../../stores/MessageStore';
+import MessageActionTouchable from '../Touchable/MessageActionTouchable';
+import messageStyles from '../../styles';
+import dayjs from '../../../../lib/dayjs';
+
+const styles = StyleSheet.create({
+ button: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ borderLeftWidth: 2
+ },
+ attachmentContainer: {
+ flex: 1,
+ borderRadius: 4,
+ flexDirection: 'row',
+ paddingVertical: 4,
+ paddingLeft: 8
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFillObject
+ },
+ authorContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4
+ },
+ titleAndDescriptionContainer: {
+ flexDirection: 'column',
+ flex: 1,
+ width: 200,
+ gap: 4
+ },
+ author: {
+ fontSize: 16,
+ ...sharedStyles.textMedium,
+ flexShrink: 1
+ },
+ fieldsContainer: {
+ flex: 1,
+ flexWrap: 'wrap',
+ flexDirection: 'row',
+ rowGap: 12
+ },
+ fieldContainer: {
+ flexDirection: 'column'
+ },
+ fieldTitle: {
+ fontSize: 14,
+ ...sharedStyles.textSemibold
+ },
+ image: {
+ height: 80,
+ width: 80,
+ borderTopLeftRadius: 4,
+ borderTopRightRadius: 4,
+ marginBottom: 1,
+ marginLeft: 20
+ },
+ title: {
+ flex: 1,
+ fontSize: 16,
+ ...sharedStyles.textMedium
+ }
+});
+
+interface IMessageReply {
+ attachment: IAttachment;
+ msg?: string;
+}
+
+const Title = ({ attachment }: { attachment: IAttachment }) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const timeFormat = useTimeFormat();
+ const time = attachment.message_link && attachment.ts ? dayjs(attachment.ts).format(timeFormat) : null;
+ return (
+
+ {attachment.author_name ? (
+
+ {attachment.author_name}
+
+ ) : null}
+ {time ? {time} : null}
+ {attachment.title ? {attachment.title} : null}
+
+ );
+};
+
+const Description = ({ attachment }: { attachment: IAttachment }) => {
+ 'use memo';
+
+ const user = useMessageUser();
+ const text = attachment.text || attachment.title;
+
+ if (!text) {
+ return null;
+ }
+
+ // For file attachments without explicit text, the title is just a filename (e.g., "test.py").
+ // We use MarkdownPreview to avoid markdown parsing treating filenames as URLs or markdown syntax.
+ // For other attachments (message quotes, embeds), the text may contain actual markdown formatting,
+ // so we use the full Markdown component to preserve styling.
+ const isFileName = attachment.type === 'file' && !attachment.text;
+
+ if (isFileName) {
+ return ;
+ }
+
+ return ;
+};
+
+const UrlImage = ({ image }: { image?: string }) => {
+ 'use memo';
+
+ const baseUrl = useBaseUrl();
+ const user = useMessageUser();
+
+ if (!image) {
+ return null;
+ }
+
+ image = image.includes('http') ? image : `${baseUrl}/${image}?rc_uid=${user?.id ?? ''}&rc_token=${user?.token ?? ''}`;
+ return ;
+};
+
+const Fields = ({ attachment }: { attachment: IAttachment }) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const user = useMessageUser();
+
+ if (!attachment.fields) {
+ return null;
+ }
+
+ return (
+
+ {attachment.fields.map(field => (
+
+ {field.title}
+
+
+ ))}
+
+ );
+};
+
+const Reply = ({ attachment, msg }: IMessageReply) => {
+ 'use memo';
+
+ const [loading, setLoading] = useState(false);
+ const { colors, theme } = useTheme();
+ const baseUrl = useBaseUrl();
+ const user = useMessageUser();
+ const id = useMessageId();
+ const isEncrypted = useIsEncrypted();
+
+ if (!attachment || isEncrypted) {
+ return null;
+ }
+
+ const onPress = async () => {
+ let url = attachment.title_link || attachment.author_link;
+ if (!url) {
+ return;
+ }
+ if (attachment.type === 'file' && attachment.title_link) {
+ setLoading(true);
+ url = formatAttachmentUrl(attachment.title_link, user?.id ?? '', user?.token ?? '', baseUrl ?? '');
+ await fileDownloadAndPreview(url, attachment, id ?? '');
+ setLoading(false);
+ return;
+ }
+ openLink(url, theme);
+ };
+
+ let { strokeLight } = colors;
+ if (attachment.color) {
+ strokeLight = attachment.color;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+
+ ) : null}
+
+
+
+
+ {msg ? : null}
+
+ );
+};
+
+export default Reply;
diff --git a/app/containers/message/Components/Attachments/Video.tsx b/app/containers/message/components/Attachments/Video.tsx
similarity index 88%
rename from app/containers/message/Components/Attachments/Video.tsx
rename to app/containers/message/components/Attachments/Video.tsx
index 6f6c2e5ba2e..4894b758997 100644
--- a/app/containers/message/Components/Attachments/Video.tsx
+++ b/app/containers/message/components/Attachments/Video.tsx
@@ -1,4 +1,4 @@
-import { useContext, type ReactElement } from 'react';
+import { type ReactElement } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { type IUserMessage } from '../../../../definitions';
@@ -11,9 +11,9 @@ import sharedStyles from '../../../../views/Styles';
import { type TIconsName } from '../../../CustomIcon';
import { LISTENER } from '../../../Toast';
import Markdown from '../../../markdown';
-import MessageContext from '../../Context';
-import Touchable from '../../Touchable';
+import MessageActionTouchable from '../Touchable/MessageActionTouchable';
import { useMediaAutoDownload } from '../../hooks/useMediaAutoDownload';
+import { useMessageUser } from '../../stores/MessageRoomStore';
import BlurComponent from '../OverlayComponent';
import { type TDownloadState } from '../../../../lib/methods/handleMediaDownload';
import messageStyles from '../../styles';
@@ -71,7 +71,7 @@ const Thumbnail = ({ status, encrypted = false }: { status: TDownloadState; encr
const Video = ({ file, showAttachment, author, msg }: IMessageVideo): ReactElement | null => {
'use memo';
- const { user } = useContext(MessageContext);
+ const user = useMessageUser();
const { status, onPress, url, isEncrypted, currentFile } = useMediaAutoDownload({ file, author, showAttachment });
const _onPress = async () => {
@@ -99,10 +99,10 @@ const Video = ({ file, showAttachment, author, msg }: IMessageVideo): ReactEleme
return (
- {msg ? : null}
-
+ {msg ? : null}
+
-
+
);
};
diff --git a/app/containers/message/Components/Attachments/components.ts b/app/containers/message/components/Attachments/components.ts
similarity index 100%
rename from app/containers/message/Components/Attachments/components.ts
rename to app/containers/message/components/Attachments/components.ts
diff --git a/app/containers/message/Components/Attachments/index.ts b/app/containers/message/components/Attachments/index.ts
similarity index 100%
rename from app/containers/message/Components/Attachments/index.ts
rename to app/containers/message/components/Attachments/index.ts
diff --git a/app/containers/message/Components/Attachments/utils.ts b/app/containers/message/components/Attachments/utils.ts
similarity index 85%
rename from app/containers/message/Components/Attachments/utils.ts
rename to app/containers/message/components/Attachments/utils.ts
index 294b89d1007..7ae916d758c 100644
--- a/app/containers/message/Components/Attachments/utils.ts
+++ b/app/containers/message/components/Attachments/utils.ts
@@ -1,5 +1,9 @@
import { type IAttachment } from '../../../../definitions';
+// Prefer a stable server-provided link as the React key; fall back to a prefixed index.
+export const getAttachmentKey = (file: IAttachment, prefix: string, index: number): string =>
+ file.title_link || file.message_link || `${prefix}-${index}`;
+
// Webhook/integration attachments can carry their own media alongside text/color (e.g. embeds).
const hasOwnMedia = (file: IAttachment): boolean => !!(file.image_url || file.audio_url || file.video_url);
diff --git a/app/containers/message/Blocks.ts b/app/containers/message/components/Blocks.ts
similarity index 60%
rename from app/containers/message/Blocks.ts
rename to app/containers/message/components/Blocks.ts
index c37fbfe230c..f8897a04a56 100644
--- a/app/containers/message/Blocks.ts
+++ b/app/containers/message/components/Blocks.ts
@@ -1,11 +1,16 @@
import { createElement } from 'react';
-import { messageBlockWithContext } from '../UIKit/MessageBlock';
-import { type IMessageBlocks } from './interfaces';
+import { messageBlockWithContext } from '../../UIKit/MessageBlock';
+import { useBlockAction, useRid } from '../stores/MessageRoomStore';
+import { useBlocks } from '../stores/MessageStore';
-const Blocks = ({ blocks, id: mid, rid, blockAction }: IMessageBlocks) => {
+const Blocks = () => {
'use memo';
+ const rid = useRid();
+ const blockAction = useBlockAction();
+ const { blocks, id: mid } = useBlocks();
+
if (blocks && blocks.length > 0) {
const appId = blocks[0]?.appId || '';
return createElement(
@@ -17,7 +22,7 @@ const Blocks = ({ blocks, id: mid, rid, blockAction }: IMessageBlocks) => {
appId,
value,
blockId,
- rid,
+ rid: rid ?? '',
mid
});
}
@@ -31,6 +36,4 @@ const Blocks = ({ blocks, id: mid, rid, blockAction }: IMessageBlocks) => {
return null;
};
-Blocks.displayName = 'MessageBlocks';
-
export default Blocks;
diff --git a/app/containers/message/components/Broadcast.tsx b/app/containers/message/components/Broadcast.tsx
new file mode 100644
index 00000000000..0fc5d9d2e83
--- /dev/null
+++ b/app/containers/message/components/Broadcast.tsx
@@ -0,0 +1,40 @@
+import { Text, View } from 'react-native';
+
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import { CustomIcon } from '../../CustomIcon';
+import styles from '../styles';
+import { BUTTON_HIT_SLOP } from '../utils';
+import I18n from '../../../i18n';
+import { useTheme } from '../../../theme';
+import { useIsOwnMessage, useMessageItem } from '../stores/MessageStore';
+import { useBroadcast, useReplyBroadcast } from '../stores/MessageRoomStore';
+
+const Broadcast = () => {
+ 'use memo';
+
+ const item = useMessageItem();
+ const replyBroadcast = useReplyBroadcast();
+ const broadcast = useBroadcast();
+ const { colors } = useTheme();
+ const isOwn = useIsOwnMessage();
+
+ if (broadcast && !isOwn) {
+ return (
+
+ replyBroadcast?.(item)}
+ style={[styles.button, { backgroundColor: colors.badgeBackgroundLevel2 }]}
+ hitSlop={BUTTON_HIT_SLOP}
+ testID='message-broadcast-reply'>
+
+
+ {I18n.t('Reply')}
+
+
+
+ );
+ }
+ return null;
+};
+
+export default Broadcast;
diff --git a/app/containers/message/components/CallButton.tsx b/app/containers/message/components/CallButton.tsx
new file mode 100644
index 00000000000..6202c8422ae
--- /dev/null
+++ b/app/containers/message/components/CallButton.tsx
@@ -0,0 +1,31 @@
+import { Text, View } from 'react-native';
+
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import { BUTTON_HIT_SLOP } from '../utils';
+import styles from '../styles';
+import I18n from '../../../i18n';
+import { CustomIcon } from '../../CustomIcon';
+import { useTheme } from '../../../theme';
+import { useHandleEnterCall } from '../stores/MessageRoomStore';
+
+const CallButton = () => {
+ 'use memo';
+
+ const handleEnterCall = useHandleEnterCall();
+ const { colors } = useTheme();
+ return (
+
+
+
+
+ {I18n.t('Click_to_join')}
+
+
+
+ );
+};
+
+export default CallButton;
diff --git a/app/containers/message/components/Content/ContentWrapper.tsx b/app/containers/message/components/Content/ContentWrapper.tsx
new file mode 100644
index 00000000000..6c749e917ed
--- /dev/null
+++ b/app/containers/message/components/Content/ContentWrapper.tsx
@@ -0,0 +1,20 @@
+import { type ReactNode } from 'react';
+import { View } from 'react-native';
+
+import styles from '../../styles';
+import { useMessageStatus, useMessageText } from '../../stores/MessageStore';
+
+const ContentWrapper = ({ children }: { children: ReactNode }) => {
+ 'use memo';
+
+ const { isTemp } = useMessageStatus();
+ const { messageText } = useMessageText();
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default ContentWrapper;
diff --git a/app/containers/message/components/Content/EncryptedContent.tsx b/app/containers/message/components/Content/EncryptedContent.tsx
new file mode 100644
index 00000000000..9233e621daf
--- /dev/null
+++ b/app/containers/message/components/Content/EncryptedContent.tsx
@@ -0,0 +1,25 @@
+import { Text } from 'react-native';
+
+import I18n from '../../../../i18n';
+import styles from '../../styles';
+import { useTheme } from '../../../../theme';
+import ContentWrapper from './ContentWrapper';
+
+const EncryptedContent = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+
+ return (
+
+
+ {I18n.t('Encrypted_message')}
+
+
+ );
+};
+
+export default EncryptedContent;
diff --git a/app/containers/message/components/Content/IgnoredContent.tsx b/app/containers/message/components/Content/IgnoredContent.tsx
new file mode 100644
index 00000000000..2dbd7c3bbb9
--- /dev/null
+++ b/app/containers/message/components/Content/IgnoredContent.tsx
@@ -0,0 +1,24 @@
+import { Text } from 'react-native';
+
+import I18n from '../../../../i18n';
+import styles from '../../styles';
+import { useTheme } from '../../../../theme';
+import { useMessageText } from '../../stores/MessageStore';
+import ContentWrapper from './ContentWrapper';
+
+const IgnoredContent = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const { messageText } = useMessageText();
+
+ return (
+
+
+ {I18n.t('Message_Ignored')}
+
+
+ );
+};
+
+export default IgnoredContent;
diff --git a/app/containers/message/components/Content/InfoContent.tsx b/app/containers/message/components/Content/InfoContent.tsx
new file mode 100644
index 00000000000..777bbcc64d6
--- /dev/null
+++ b/app/containers/message/components/Content/InfoContent.tsx
@@ -0,0 +1,37 @@
+import { Text } from 'react-native';
+
+import styles from '../../styles';
+import { getInfoMessage, messageHaveAuthorName } from '../../utils';
+import { useTheme } from '../../../../theme';
+import { type MessageTypesValues } from '../../../../definitions';
+import { useInfoData, useMessageAuthor, useMessageText } from '../../stores/MessageStore';
+import User from '../User';
+
+const InfoContent = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const { t: type, comment } = useInfoData();
+ const { u: author, role } = useMessageAuthor();
+ const { messageText } = useMessageText();
+
+ const infoMessage = getInfoMessage({ type, role, msg: messageText, author, comment });
+
+ const renderMessageContent = (
+
+ {infoMessage}
+
+ );
+
+ if (messageHaveAuthorName(type as MessageTypesValues)) {
+ return (
+
+ {renderMessageContent}
+
+ );
+ }
+
+ return renderMessageContent;
+};
+
+export default InfoContent;
diff --git a/app/containers/message/components/Content/MarkdownContent.tsx b/app/containers/message/components/Content/MarkdownContent.tsx
new file mode 100644
index 00000000000..4afc0a2b7c8
--- /dev/null
+++ b/app/containers/message/components/Content/MarkdownContent.tsx
@@ -0,0 +1,34 @@
+import Markdown from '../../../markdown';
+import { useSetting } from '../../../../lib/hooks/useSetting';
+import { useMarkdownData, useMessageText, useOnLinkPress } from '../../stores/MessageStore';
+import { useMessageUser, useNavToRoomInfo } from '../../stores/MessageRoomStore';
+import ContentWrapper from './ContentWrapper';
+
+const MarkdownContent = () => {
+ 'use memo';
+
+ const useRealName = useSetting('UI_Use_Real_Name') as boolean;
+ const user = useMessageUser();
+ const onLinkPress = useOnLinkPress();
+ const navToRoomInfo = useNavToRoomInfo();
+ const { md, mentions, channels, t: type } = useMarkdownData();
+ const { messageText, isTranslated } = useMessageText();
+
+ return (
+
+
+
+ );
+};
+
+export default MarkdownContent;
diff --git a/app/containers/message/components/Content/PreviewContent.tsx b/app/containers/message/components/Content/PreviewContent.tsx
new file mode 100644
index 00000000000..11fc99fea25
--- /dev/null
+++ b/app/containers/message/components/Content/PreviewContent.tsx
@@ -0,0 +1,28 @@
+import { MarkdownPreview } from '../../../markdown';
+import { getPreviewMessageFromAttachment } from '../../utils';
+import { useAttachments, useMessageText } from '../../stores/MessageStore';
+import { useAutoTranslate } from '../../stores/MessageRoomStore';
+import ContentWrapper from './ContentWrapper';
+
+const PreviewContent = () => {
+ 'use memo';
+
+ const { messageText } = useMessageText();
+ const attachments = useAttachments();
+ const { autoTranslateLanguage } = useAutoTranslate();
+
+ const previewMsg =
+ messageText || (attachments?.length ? getPreviewMessageFromAttachment(attachments[0], autoTranslateLanguage) : undefined);
+
+ if (!previewMsg) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default PreviewContent;
diff --git a/app/containers/message/components/Content/index.tsx b/app/containers/message/components/Content/index.tsx
new file mode 100644
index 00000000000..b38ed94725e
--- /dev/null
+++ b/app/containers/message/components/Content/index.tsx
@@ -0,0 +1,45 @@
+import EncryptedContent from './EncryptedContent';
+import IgnoredContent from './IgnoredContent';
+import InfoContent from './InfoContent';
+import MarkdownContent from './MarkdownContent';
+import PreviewContent from './PreviewContent';
+import { useIsEncrypted, useIsInfoMessage, useMessageIgnored, useMessageText, useThreadData } from '../../stores/MessageStore';
+import { useIsThreadRoom } from '../../stores/MessageRoomStore';
+
+// isInfo forces InfoContent regardless of useIsInfoMessage — jitsi_call_started is deliberately
+// excluded there (it isn't a compact/non-touchable info row) but still needs the info-style body.
+const Content = ({ isInfo: isInfoProp }: { isInfo?: boolean } = {}) => {
+ 'use memo';
+
+ const isInfoMessage = useIsInfoMessage();
+ const isInfo = isInfoProp ?? isInfoMessage;
+ const isIgnored = useMessageIgnored();
+ const isEncrypted = useIsEncrypted();
+ const { tmid } = useThreadData();
+ const isThreadRoom = useIsThreadRoom();
+ const { messageText } = useMessageText();
+
+ if (isInfo) {
+ return ;
+ }
+
+ if (isIgnored) {
+ return ;
+ }
+
+ if (isEncrypted) {
+ return ;
+ }
+
+ if (tmid && !isThreadRoom) {
+ return ;
+ }
+
+ if (messageText) {
+ return ;
+ }
+
+ return null;
+};
+
+export default Content;
diff --git a/app/containers/message/components/Discussion.tsx b/app/containers/message/components/Discussion.tsx
new file mode 100644
index 00000000000..13e80c630eb
--- /dev/null
+++ b/app/containers/message/components/Discussion.tsx
@@ -0,0 +1,47 @@
+import { Text, View } from 'react-native';
+
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import { BUTTON_HIT_SLOP, formatMessageCount } from '../utils';
+import styles from '../styles';
+import I18n from '../../../i18n';
+import { CustomIcon } from '../../CustomIcon';
+import { DISCUSSION } from '../constants';
+import { formatDateThreads } from '../../../lib/methods/helpers/room';
+import { useTheme } from '../../../theme';
+import { useDiscussion, useMessageField, useMessageText } from '../stores/MessageStore';
+import { useOnDiscussionPress } from '../stores/MessageRoomStore';
+
+const Discussion = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const drid = useMessageField(item => item.drid);
+ const { dcount, dlm } = useDiscussion();
+ const { messageText } = useMessageText();
+ let time;
+ if (dlm) {
+ time = formatDateThreads(dlm);
+ }
+ const buttonText = formatMessageCount(dcount, DISCUSSION);
+ const onDiscussionPress = useOnDiscussionPress();
+ return (
+
+ {I18n.t('Started_discussion')}
+ {messageText}
+
+ onDiscussionPress?.(drid)}
+ style={[styles.button, { backgroundColor: colors.badgeBackgroundLevel2 }]}
+ hitSlop={BUTTON_HIT_SLOP}>
+
+
+ {buttonText}
+
+
+ {time}
+
+
+ );
+};
+
+export default Discussion;
diff --git a/app/containers/message/components/Emoji.tsx b/app/containers/message/components/Emoji.tsx
new file mode 100644
index 00000000000..d3e4ed5cb79
--- /dev/null
+++ b/app/containers/message/components/Emoji.tsx
@@ -0,0 +1,21 @@
+import { Text } from 'react-native';
+
+import useShortnameToUnicode from '../../../lib/hooks/useShortnameToUnicode';
+import { useCustomEmoji } from '../../../lib/hooks/useCustomEmoji';
+import CustomEmoji from '../../EmojiPicker/CustomEmoji';
+import { type IMessageEmoji } from '../interfaces';
+
+const Emoji = ({ content, standardEmojiStyle, customEmojiStyle }: IMessageEmoji) => {
+ 'use memo';
+
+ const getCustomEmoji = useCustomEmoji();
+ const parsedContent = content.replace(/^:|:$/g, '');
+ const emoji = getCustomEmoji(parsedContent);
+ const { formatShortnameToUnicode } = useShortnameToUnicode();
+ if (emoji) {
+ return ;
+ }
+ return {formatShortnameToUnicode(content)};
+};
+
+export default Emoji;
diff --git a/app/containers/message/components/Layout/BlocksLayout.tsx b/app/containers/message/components/Layout/BlocksLayout.tsx
new file mode 100644
index 00000000000..31d3c0eadfb
--- /dev/null
+++ b/app/containers/message/components/Layout/BlocksLayout.tsx
@@ -0,0 +1,21 @@
+import User from '../User';
+import Blocks from '../Blocks';
+import Thread from '../Thread';
+import Reactions from '../Reactions';
+import MessageTime from '../Time';
+
+const BlocksLayout = ({ showTimeLarge }: { showTimeLarge: boolean }) => {
+ 'use memo';
+
+ return (
+ <>
+
+
+
+
+ {showTimeLarge ? : null}
+ >
+ );
+};
+
+export default BlocksLayout;
diff --git a/app/containers/message/components/Layout/ContentLayout.tsx b/app/containers/message/components/Layout/ContentLayout.tsx
new file mode 100644
index 00000000000..0cabe7aad85
--- /dev/null
+++ b/app/containers/message/components/Layout/ContentLayout.tsx
@@ -0,0 +1,20 @@
+import Content from '../Content';
+import Attachments from '../Attachments';
+import Quote from '../Attachments/Quote';
+import Urls from '../Urls';
+import { useAttachments } from '../../stores/MessageStore';
+
+export const ContentLayout = () => {
+ 'use memo';
+
+ const attachments = useAttachments();
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+};
diff --git a/app/containers/message/components/Layout/DiscussionLayout.tsx b/app/containers/message/components/Layout/DiscussionLayout.tsx
new file mode 100644
index 00000000000..11ea3071be9
--- /dev/null
+++ b/app/containers/message/components/Layout/DiscussionLayout.tsx
@@ -0,0 +1,17 @@
+import User from '../User';
+import Discussion from '../Discussion';
+import MessageTime from '../Time';
+
+const DiscussionLayout = ({ showTimeLarge }: { showTimeLarge: boolean }) => {
+ 'use memo';
+
+ return (
+ <>
+
+ {showTimeLarge ? : null}
+
+ >
+ );
+};
+
+export default DiscussionLayout;
diff --git a/app/containers/message/components/Layout/JitsiLayout.tsx b/app/containers/message/components/Layout/JitsiLayout.tsx
new file mode 100644
index 00000000000..314edcc7e46
--- /dev/null
+++ b/app/containers/message/components/Layout/JitsiLayout.tsx
@@ -0,0 +1,19 @@
+import User from '../User';
+import Content from '../Content';
+import CallButton from '../CallButton';
+import MessageTime from '../Time';
+
+const JitsiLayout = ({ showTimeLarge }: { showTimeLarge: boolean }) => {
+ 'use memo';
+
+ return (
+ <>
+
+
+
+ {showTimeLarge ? : null}
+ >
+ );
+};
+
+export default JitsiLayout;
diff --git a/app/containers/message/components/Layout/PreviewLayout.tsx b/app/containers/message/components/Layout/PreviewLayout.tsx
new file mode 100644
index 00000000000..ad9703af3f6
--- /dev/null
+++ b/app/containers/message/components/Layout/PreviewLayout.tsx
@@ -0,0 +1,17 @@
+import User from '../User';
+import MessageTime from '../Time';
+import { ContentLayout } from './ContentLayout';
+
+const PreviewLayout = ({ showTimeLarge }: { showTimeLarge: boolean }) => {
+ 'use memo';
+
+ return (
+ <>
+
+ {showTimeLarge ? : null}
+
+ >
+ );
+};
+
+export default PreviewLayout;
diff --git a/app/containers/message/components/Layout/StandardLayout.tsx b/app/containers/message/components/Layout/StandardLayout.tsx
new file mode 100644
index 00000000000..083e388fd7b
--- /dev/null
+++ b/app/containers/message/components/Layout/StandardLayout.tsx
@@ -0,0 +1,27 @@
+import { View } from 'react-native';
+
+import User from '../User';
+import Thread from '../Thread';
+import Reactions from '../Reactions';
+import Broadcast from '../Broadcast';
+import MessageTime from '../Time';
+import { ContentLayout } from './ContentLayout';
+
+const StandardLayout = ({ showTimeLarge }: { showTimeLarge: boolean }) => {
+ 'use memo';
+
+ return (
+ <>
+
+ {showTimeLarge ? : null}
+
+
+
+
+
+
+ >
+ );
+};
+
+export default StandardLayout;
diff --git a/app/containers/message/components/Layout/index.tsx b/app/containers/message/components/Layout/index.tsx
new file mode 100644
index 00000000000..aff8b95f069
--- /dev/null
+++ b/app/containers/message/components/Layout/index.tsx
@@ -0,0 +1,32 @@
+import { WidthAwareView } from '../WidthAwareView';
+import { useResponsiveLayout } from '../../../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
+import BlocksLayout from './BlocksLayout';
+import JitsiLayout from './JitsiLayout';
+import DiscussionLayout from './DiscussionLayout';
+import PreviewLayout from './PreviewLayout';
+import StandardLayout from './StandardLayout';
+import { useBlocks, useMessageField } from '../../stores/MessageStore';
+
+export const Layout = ({ isPreview, isHeader }: { isPreview?: boolean; isHeader: boolean }) => {
+ 'use memo';
+
+ const { isLargeFontScale } = useResponsiveLayout();
+ const type = useMessageField(item => item.t);
+ const { blocks } = useBlocks();
+ const showTimeLarge = isLargeFontScale && isHeader;
+
+ let layout;
+ if (blocks && blocks.length) {
+ layout = ;
+ } else if (type === 'jitsi_call_started') {
+ layout = ;
+ } else if (type === 'discussion-created') {
+ layout = ;
+ } else if (isPreview) {
+ layout = ;
+ } else {
+ layout = ;
+ }
+
+ return {layout};
+};
diff --git a/app/containers/message/components/Message/CompactMessage.tsx b/app/containers/message/components/Message/CompactMessage.tsx
new file mode 100644
index 00000000000..89b18a83605
--- /dev/null
+++ b/app/containers/message/components/Message/CompactMessage.tsx
@@ -0,0 +1,42 @@
+import { View, type ViewStyle } from 'react-native';
+
+import styles from '../../styles';
+import RepliedThread from '../RepliedThread';
+import MessageAvatar from '../MessageAvatar';
+import MessageAccessibleIndex from '../MessageAccessibleIndex';
+import Content from '../Content';
+import Attachments from '../Attachments';
+import { useContentData, useIsInfoMessage, useMessageGrouping, useThreadPosition } from '../../stores/MessageStore';
+
+const CompactMessage = () => {
+ 'use memo';
+
+ const isHeader = useMessageGrouping();
+ const { isThreadReply } = useThreadPosition();
+ const isInfo = useIsInfoMessage();
+ const { t: type, attachments } = useContentData();
+
+ const thread = isThreadReply ? : null;
+ const infoStyle: ViewStyle = isInfo ? { alignItems: 'center' } : {};
+
+ return (
+
+ {thread}
+
+
+
+
+
+ {isInfo && type === 'message_pinned' ? (
+
+
+
+ ) : null}
+
+
+
+
+ );
+};
+
+export default CompactMessage;
diff --git a/app/containers/message/components/Message/FullMessage.tsx b/app/containers/message/components/Message/FullMessage.tsx
new file mode 100644
index 00000000000..61fb17837b3
--- /dev/null
+++ b/app/containers/message/components/Message/FullMessage.tsx
@@ -0,0 +1,31 @@
+import { View } from 'react-native';
+
+import styles from '../../styles';
+import MessageAvatar from '../MessageAvatar';
+import MessageAccessibleIndex from '../MessageAccessibleIndex';
+import RightIcons from '../RightIcons';
+import { Layout } from '../Layout';
+import { useMessageField, useMessageGrouping } from '../../stores/MessageStore';
+
+const FullMessage = ({ isPreview }: { isPreview?: boolean }) => {
+ 'use memo';
+
+ const isHeader = useMessageGrouping();
+ const id = useMessageField(item => item.id);
+
+ return (
+
+
+
+
+
+
+
+ {!isHeader ? : null}
+
+
+
+ );
+};
+
+export default FullMessage;
diff --git a/app/containers/message/components/Message/Message.tsx b/app/containers/message/components/Message/Message.tsx
new file mode 100644
index 00000000000..860d5b2d4c3
--- /dev/null
+++ b/app/containers/message/components/Message/Message.tsx
@@ -0,0 +1,24 @@
+import CompactMessage from './CompactMessage';
+import FullMessage from './FullMessage';
+import { useIsInfoMessage, useMessageIgnored, useThreadPosition } from '../../stores/MessageStore';
+
+export type TMessageProps = {
+ isPreview?: boolean;
+ highlighted?: boolean;
+};
+
+const Message = (props: TMessageProps) => {
+ 'use memo';
+
+ const { isThreadReply, isThreadSequential } = useThreadPosition();
+ const isInfo = useIsInfoMessage();
+ const isIgnored = useMessageIgnored();
+
+ if (isThreadReply || isThreadSequential || isInfo || isIgnored) {
+ return ;
+ }
+
+ return ;
+};
+
+export default Message;
diff --git a/app/containers/message/components/MessageAccessibleIndex.tsx b/app/containers/message/components/MessageAccessibleIndex.tsx
new file mode 100644
index 00000000000..cc1fd812da0
--- /dev/null
+++ b/app/containers/message/components/MessageAccessibleIndex.tsx
@@ -0,0 +1,26 @@
+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';
+
+const MessageAccessibleIndex = ({ style, children }: { style?: StyleProp; children: ReactNode }) => {
+ 'use memo';
+
+ const { messageText, isTranslated } = useMessageText();
+ const { autoTranslateLanguage } = useAutoTranslate();
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default MessageAccessibleIndex;
diff --git a/app/containers/message/MessageAvatar.tsx b/app/containers/message/components/MessageAvatar.tsx
similarity index 55%
rename from app/containers/message/MessageAvatar.tsx
rename to app/containers/message/components/MessageAvatar.tsx
index 95a824197ae..0057a0dc512 100644
--- a/app/containers/message/MessageAvatar.tsx
+++ b/app/containers/message/components/MessageAvatar.tsx
@@ -1,12 +1,13 @@
-import { useContext, memo, type ReactElement } from 'react';
+import { type ReactElement } from 'react';
import { View } from 'react-native';
-import Avatar from '../Avatar';
-import styles from './styles';
-import MessageContext from './Context';
-import { type IMessageAvatar } from './interfaces';
-import { SubscriptionType } from '../../definitions';
-import { useResponsiveLayout } from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
+import Avatar from '../../Avatar';
+import styles from '../styles';
+import { type IMessageAvatar } from '../interfaces';
+import { SubscriptionType } from '../../../definitions';
+import { useResponsiveLayout } from '../../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
+import { useAvatar, useMessageField, useMessageGrouping } from '../stores/MessageStore';
+import { useMessageUser, useNavToRoomInfo } from '../stores/MessageRoomStore';
const AVATAR_BASE_SIZE = 36;
@@ -16,21 +17,25 @@ export const AvatarContainer = ({ children }: { children?: ReactElement | null }
return {children};
};
-const MessageAvatar = memo(({ isHeader, avatar, author, small, navToRoomInfo, emoji }: IMessageAvatar) => {
+const MessageAvatar = ({ small }: IMessageAvatar) => {
'use memo';
- const { user } = useContext(MessageContext);
+ const user = useMessageUser();
+ const navToRoomInfo = useNavToRoomInfo();
const { fontScaleLimited } = useResponsiveLayout();
+ const { avatar, emoji } = useAvatar();
+ const author = useMessageField(item => item.u);
+ const isHeader = useMessageGrouping();
const smallSize = 20 * fontScaleLimited;
const normalSize = AVATAR_BASE_SIZE * fontScaleLimited;
const size = small ? smallSize : normalSize;
if (isHeader && author) {
const onPress = () =>
- navToRoomInfo({
+ navToRoomInfo?.({
t: SubscriptionType.DIRECT,
rid: author._id,
- itsMe: author._id === user.id
+ itsMe: author._id === user?.id
});
return (
@@ -48,8 +53,6 @@ const MessageAvatar = memo(({ isHeader, avatar, author, small, navToRoomInfo, em
);
}
return ;
-});
-
-MessageAvatar.displayName = 'MessageAvatar';
+};
export default MessageAvatar;
diff --git a/app/containers/message/Components/OverlayComponent/index.tsx b/app/containers/message/components/OverlayComponent.tsx
similarity index 75%
rename from app/containers/message/Components/OverlayComponent/index.tsx
rename to app/containers/message/components/OverlayComponent.tsx
index 9d06de4001b..938bd0ecfca 100644
--- a/app/containers/message/Components/OverlayComponent/index.tsx
+++ b/app/containers/message/components/OverlayComponent.tsx
@@ -1,9 +1,9 @@
import { type StyleProp, View, type ViewStyle } from 'react-native';
-import styles from '../../styles';
-import { useTheme } from '../../../../theme';
-import RCActivityIndicator from '../../../ActivityIndicator';
-import { CustomIcon, type TIconsName } from '../../../CustomIcon';
+import styles from '../styles';
+import { useTheme } from '../../../theme';
+import RCActivityIndicator from '../../ActivityIndicator';
+import { CustomIcon, type TIconsName } from '../../CustomIcon';
const OverlayComponent = ({
loading = false,
diff --git a/app/containers/message/components/Preview.tsx b/app/containers/message/components/Preview.tsx
new file mode 100644
index 00000000000..7e3c22910be
--- /dev/null
+++ b/app/containers/message/components/Preview.tsx
@@ -0,0 +1,27 @@
+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 { type TAnyMessageModel } from '../../../definitions';
+
+const MessagePreview = ({ message }: { message: TAnyMessageModel }) => {
+ 'use memo';
+
+ const { user, baseUrl } = useAppSelector(
+ state => ({
+ user: getUserSelector(state),
+ baseUrl: state.server.server
+ }),
+ shallowEqual
+ );
+
+ return (
+
+
+
+ );
+};
+
+export default MessagePreview;
diff --git a/app/containers/message/components/Reactions.tsx b/app/containers/message/components/Reactions.tsx
new file mode 100644
index 00000000000..9129bc17e17
--- /dev/null
+++ b/app/containers/message/components/Reactions.tsx
@@ -0,0 +1,99 @@
+import { Text, useWindowDimensions, View } from 'react-native';
+
+import I18n from '../../../i18n';
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import { CustomIcon } from '../../CustomIcon';
+import styles from '../styles';
+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';
+
+interface IReaction {
+ _id: string;
+ emoji: string;
+ usernames: string[];
+}
+
+interface IMessageReaction {
+ reaction: IReaction;
+}
+
+const AddReaction = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const reactionInit = useReactionInit();
+ const id = useMessageId();
+ const { fontScale } = useWindowDimensions();
+ const height = 28 * fontScale;
+ return (
+ reactionInit?.(id)}
+ key='message-add-reaction'
+ testID='message-add-reaction'
+ accessibilityRole='button'
+ accessibilityLabel={I18n.t('Add_reaction')}
+ style={[styles.reactionButton, { backgroundColor: colors.surfaceRoom }]}
+ hitSlop={BUTTON_HIT_SLOP}
+ android_ripple={{ color: colors.strokeLight }}>
+
+
+
+
+ );
+};
+
+const Reaction = ({ reaction }: IMessageReaction) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const item = useMessageItem();
+ const id = useMessageId();
+ const onReactionPress = useOnReactionPress();
+ const onReactionLongPress = useOnReactionLongPress();
+ const user = useMessageUser();
+ const { fontScale } = useWindowDimensions();
+ const height = 28 * fontScale;
+ const reacted = reaction.usernames.findIndex((item: string) => item === user?.username) !== -1;
+ return (
+ onReactionPress?.(reaction.emoji, id)}
+ onLongPress={() => onReactionLongPress?.(item)}
+ key={reaction.emoji}
+ testID={`message-reaction-${reaction.emoji}`}
+ accessibilityRole='button'
+ accessibilityLabel={`${reaction.emoji}, ${reaction.usernames.length}`}
+ accessibilityState={{ selected: reacted }}
+ style={[styles.reactionButton, { backgroundColor: reacted ? colors.surfaceNeutral : colors.surfaceRoom }]}
+ hitSlop={BUTTON_HIT_SLOP}
+ android_ripple={{ color: colors.strokeLight }}>
+
+
+ {reaction.usernames.length}
+
+
+ );
+};
+
+const Reactions = () => {
+ 'use memo';
+
+ const reactions = useReactions();
+
+ if (!Array.isArray(reactions) || reactions.length === 0) {
+ return null;
+ }
+ return (
+
+ {reactions.map(reaction => (
+
+ ))}
+
+
+ );
+};
+
+export default Reactions;
diff --git a/app/containers/message/components/RepliedThread.tsx b/app/containers/message/components/RepliedThread.tsx
new file mode 100644
index 00000000000..900a502be18
--- /dev/null
+++ b/app/containers/message/components/RepliedThread.tsx
@@ -0,0 +1,64 @@
+import { useEffect, useState } from 'react';
+import { View } from 'react-native';
+
+import { CustomIcon } from '../../CustomIcon';
+import styles from '../styles';
+import I18n from '../../../i18n';
+import { MarkdownPreview } from '../../markdown';
+import { type IMessageRepliedThread } from '../interfaces';
+import { useTheme } from '../../../theme';
+import { AvatarContainer } from './MessageAvatar';
+import { useFetchThreadName } from '../stores/MessageRoomStore';
+import { useIsEncrypted, useRepliedThreadData } from '../stores/MessageStore';
+
+const RepliedThread = ({ isHeader }: IMessageRepliedThread) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const fetchThreadName = useFetchThreadName();
+ const { tmid, tmsg, id } = useRepliedThreadData();
+ const isEncrypted = useIsEncrypted();
+ const displayMsg = isEncrypted ? I18n.t('Encrypted_message') : tmsg;
+ const [fetchedName, setFetchedName] = useState();
+
+ useEffect(() => {
+ if (displayMsg || !tmid) {
+ return;
+ }
+ let ignore = false;
+ const fetch = async () => {
+ const threadName = fetchThreadName ? await fetchThreadName(tmid, id) : '';
+ if (!ignore) {
+ setFetchedName(threadName);
+ }
+ };
+ fetch();
+ return () => {
+ ignore = true;
+ };
+ }, [tmid, id, displayMsg, fetchThreadName]);
+
+ if (!tmid || !isHeader) {
+ return null;
+ }
+
+ const msg = displayMsg || fetchedName;
+
+ if (!msg) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RepliedThread;
diff --git a/app/containers/message/Components/RightIcons/Edited.tsx b/app/containers/message/components/RightIcons/Edited.tsx
similarity index 51%
rename from app/containers/message/Components/RightIcons/Edited.tsx
rename to app/containers/message/components/RightIcons/Edited.tsx
index d75d78ed3fe..0b865ed17d1 100644
--- a/app/containers/message/Components/RightIcons/Edited.tsx
+++ b/app/containers/message/components/RightIcons/Edited.tsx
@@ -1,21 +1,24 @@
-import { memo } from 'react';
import { View } from 'react-native';
import { CustomIcon } from '../../../CustomIcon';
import styles from '../../styles';
+import { useIsEdited, useMessageText } from '../../stores/MessageStore';
-const Edited = memo(({ isEdited, testID }: { isEdited: boolean; testID?: string }) => {
+const Edited = () => {
'use memo';
+ const isEdited = useIsEdited();
+ const { messageText } = useMessageText();
+
if (!isEdited) {
return null;
}
return (
-
+
);
-});
+};
export default Edited;
diff --git a/app/containers/message/components/RightIcons/Encrypted.tsx b/app/containers/message/components/RightIcons/Encrypted.tsx
new file mode 100644
index 00000000000..65f2ee8412f
--- /dev/null
+++ b/app/containers/message/components/RightIcons/Encrypted.tsx
@@ -0,0 +1,26 @@
+import MessageActionTouchable from '../Touchable/MessageActionTouchable';
+import { CustomIcon } from '../../../CustomIcon';
+import { BUTTON_HIT_SLOP } from '../../utils';
+import styles from '../../styles';
+import { E2E_MESSAGE_TYPE } from '../../../../lib/constants/keys';
+import { useMessageField } from '../../stores/MessageStore';
+import { useOnEncryptedPress } from '../../stores/MessageRoomStore';
+
+const Encrypted = () => {
+ 'use memo';
+
+ const onEncryptedPress = useOnEncryptedPress();
+ const type = useMessageField(item => item.t);
+
+ if (type !== E2E_MESSAGE_TYPE) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+};
+
+export default Encrypted;
diff --git a/app/containers/message/components/RightIcons/MessageError.tsx b/app/containers/message/components/RightIcons/MessageError.tsx
new file mode 100644
index 00000000000..ecb71ae6db6
--- /dev/null
+++ b/app/containers/message/components/RightIcons/MessageError.tsx
@@ -0,0 +1,28 @@
+import MessageActionTouchable from '../Touchable/MessageActionTouchable';
+import { CustomIcon } from '../../../CustomIcon';
+import styles from '../../styles';
+import { BUTTON_HIT_SLOP } from '../../utils';
+import { useTheme } from '../../../../theme';
+import { useMessageItem, useMessageStatus } from '../../stores/MessageStore';
+import { useErrorActionsShow } from '../../stores/MessageRoomStore';
+
+const MessageError = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const item = useMessageItem();
+ const errorActionsShow = useErrorActionsShow();
+ const { hasError } = useMessageStatus();
+
+ if (!hasError) {
+ return null;
+ }
+
+ return (
+ errorActionsShow?.(item)} style={styles.rightIcons} hitSlop={BUTTON_HIT_SLOP}>
+
+
+ );
+};
+
+export default MessageError;
diff --git a/app/containers/message/components/RightIcons/Pinned.tsx b/app/containers/message/components/RightIcons/Pinned.tsx
new file mode 100644
index 00000000000..665cf53bfc4
--- /dev/null
+++ b/app/containers/message/components/RightIcons/Pinned.tsx
@@ -0,0 +1,17 @@
+import { type ReactElement } from 'react';
+
+import { CustomIcon } from '../../../CustomIcon';
+import styles from '../../styles';
+import { useMessageField, useMessageText } from '../../stores/MessageStore';
+
+const Pinned = (): ReactElement | null => {
+ 'use memo';
+
+ const pinned = useMessageField(item => item.pinned);
+ const { messageText } = useMessageText();
+
+ if (pinned) return ;
+ return null;
+};
+
+export default Pinned;
diff --git a/app/containers/message/Components/RightIcons/ReadReceipt.tsx b/app/containers/message/components/RightIcons/ReadReceipt.tsx
similarity index 70%
rename from app/containers/message/Components/RightIcons/ReadReceipt.tsx
rename to app/containers/message/components/RightIcons/ReadReceipt.tsx
index ce1d0e4fa49..f96e30c4b1d 100644
--- a/app/containers/message/Components/RightIcons/ReadReceipt.tsx
+++ b/app/containers/message/components/RightIcons/ReadReceipt.tsx
@@ -1,13 +1,15 @@
-import { memo } from 'react';
-
import { CustomIcon } from '../../../CustomIcon';
import styles from '../../styles';
import { useTheme } from '../../../../theme';
+import { useMessageField } from '../../stores/MessageStore';
+import { useIsReadReceiptEnabled } from '../../stores/MessageRoomStore';
-const ReadReceipt = memo(({ isReadReceiptEnabled, unread }: { isReadReceiptEnabled?: boolean; unread?: boolean }) => {
+const ReadReceipt = () => {
'use memo';
const { colors } = useTheme();
+ const unread = useMessageField(item => item.unread);
+ const isReadReceiptEnabled = useIsReadReceiptEnabled();
const isUnread = unread || unread === null;
const iconName = isUnread ? 'check-single' : 'check-double';
const iconColor = isUnread ? colors.fontAnnotation : colors.fontInfo;
@@ -25,7 +27,6 @@ const ReadReceipt = memo(({ isReadReceiptEnabled, unread }: { isReadReceiptEnabl
);
}
return null;
-});
-ReadReceipt.displayName = 'MessageReadReceipt';
+};
export default ReadReceipt;
diff --git a/app/containers/message/Components/RightIcons/Translated.tsx b/app/containers/message/components/RightIcons/Translated.tsx
similarity index 69%
rename from app/containers/message/Components/RightIcons/Translated.tsx
rename to app/containers/message/components/RightIcons/Translated.tsx
index fa1393b68fe..a8e3f7a9452 100644
--- a/app/containers/message/Components/RightIcons/Translated.tsx
+++ b/app/containers/message/components/RightIcons/Translated.tsx
@@ -1,12 +1,14 @@
-import { memo } from 'react';
import { View } from 'react-native';
import { CustomIcon } from '../../../CustomIcon';
import styles from '../../styles';
+import { useMessageText } from '../../stores/MessageStore';
-const Translated = memo(({ isTranslated }: { isTranslated: boolean }) => {
+const Translated = () => {
'use memo';
+ const { isTranslated } = useMessageText();
+
if (!isTranslated) {
return null;
}
@@ -16,6 +18,6 @@ const Translated = memo(({ isTranslated }: { isTranslated: boolean }) => {
);
-});
+};
export default Translated;
diff --git a/app/containers/message/components/RightIcons/index.tsx b/app/containers/message/components/RightIcons/index.tsx
new file mode 100644
index 00000000000..a4e6d206357
--- /dev/null
+++ b/app/containers/message/components/RightIcons/index.tsx
@@ -0,0 +1,32 @@
+import { type ReactElement } from 'react';
+import { StyleSheet, View } from 'react-native';
+
+import Edited from './Edited';
+import Encrypted from './Encrypted';
+import MessageError from './MessageError';
+import Pinned from './Pinned';
+import ReadReceipt from './ReadReceipt';
+import Translated from './Translated';
+
+const styles = StyleSheet.create({
+ actionIcons: {
+ flexDirection: 'row'
+ }
+});
+
+const RightIcons = (): ReactElement => {
+ 'use memo';
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RightIcons;
diff --git a/app/containers/message/components/Thread.tsx b/app/containers/message/components/Thread.tsx
new file mode 100644
index 00000000000..fdfbd0d8360
--- /dev/null
+++ b/app/containers/message/components/Thread.tsx
@@ -0,0 +1,57 @@
+import { Text, View } from 'react-native';
+
+import styles from '../styles';
+import ThreadDetails from '../../ThreadDetails';
+import I18n from '../../../i18n';
+import { useTheme } from '../../../theme';
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import { useMessageItem, useMessageText, useReplies, useThreadBadgeColor, useThreadData } from '../stores/MessageStore';
+import { useIsThreadRoom, useMessageUser, useOnThreadPress, useToggleFollowThread } from '../stores/MessageRoomStore';
+
+const Thread = () => {
+ 'use memo';
+
+ const { theme, colors } = useTheme();
+ const item = useMessageItem();
+ const isThreadRoom = useIsThreadRoom();
+ const threadBadgeColor = useThreadBadgeColor();
+ const toggleFollowThread = useToggleFollowThread();
+ const user = useMessageUser();
+ const replies = useReplies();
+ const onThreadPress = useOnThreadPress();
+ const { tcount, tlm, id } = useThreadData();
+ const { messageText } = useMessageText();
+
+ const backgroundColor = threadBadgeColor ? colors.badgeBackgroundLevel2 : colors.buttonBackgroundSecondaryDefault;
+ const textColor = threadBadgeColor || theme !== 'light' ? colors.fontWhite : colors.fontPureBlack;
+
+ if (!tlm || isThreadRoom || tcount === null) {
+ return null;
+ }
+
+ return (
+
+ onThreadPress?.(item)}
+ accessibilityRole='button'
+ accessibilityLabel={I18n.t('View_Thread')}
+ style={[styles.button, { backgroundColor }]}
+ testID={`message-thread-button-${messageText}`}>
+ {I18n.t('View_Thread')}
+
+
+
+ );
+};
+
+export default Thread;
diff --git a/app/containers/message/components/Time.tsx b/app/containers/message/components/Time.tsx
new file mode 100644
index 00000000000..b787f1d1ede
--- /dev/null
+++ b/app/containers/message/components/Time.tsx
@@ -0,0 +1,21 @@
+import { Text } from 'react-native';
+
+import dayjs from '../../../lib/dayjs';
+import { useTheme } from '../../../theme';
+import messageStyles from '../styles';
+import { useMessageField } from '../stores/MessageStore';
+import { useTimeFormat } from '../stores/MessageRoomStore';
+
+const MessageTime = () => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const ts = useMessageField(item => item.ts);
+ const timeFormat = useTimeFormat();
+
+ const time = dayjs(ts).format(timeFormat);
+
+ return {time};
+};
+
+export default MessageTime;
diff --git a/app/containers/message/components/Touchable/MessageActionTouchable.tsx b/app/containers/message/components/Touchable/MessageActionTouchable.tsx
new file mode 100644
index 00000000000..3a03fdbf802
--- /dev/null
+++ b/app/containers/message/components/Touchable/MessageActionTouchable.tsx
@@ -0,0 +1,23 @@
+import { type ReactNode, type FC } from 'react';
+import { Pressable, type PressableProps } from 'react-native';
+
+import { useMessageLongPress } from '../../stores/MessageStore';
+
+interface IProps extends PressableProps {
+ children: ReactNode;
+ onLongPress?: () => void;
+}
+
+const MessageActionTouchable: FC = ({ children, ...props }) => {
+ 'use memo';
+
+ const onLongPress = useMessageLongPress();
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default MessageActionTouchable;
diff --git a/app/containers/message/components/Touchable/MessageTouchable.tsx b/app/containers/message/components/Touchable/MessageTouchable.tsx
new file mode 100644
index 00000000000..e4e39c43855
--- /dev/null
+++ b/app/containers/message/components/Touchable/MessageTouchable.tsx
@@ -0,0 +1,82 @@
+import { A11y } from 'react-native-a11y-order';
+
+import { useTheme } from '../../../../theme';
+import Touch from './Touch';
+import Message, { type TMessageProps } from '../Message/Message';
+import { useLastFocusedMessageRef } from '../../../../lib/a11y/useLastFocusedMessageRef';
+import { useMessageAccessibilityLabel } from '../../hooks/useMessageAccessibilityLabel';
+import { useMessageAccessibilityActions } from '../../hooks/useMessageAccessibilityActions';
+import { useMessageAccessibilityHint } from '../../hooks/useMessageAccessibilityHint';
+import { useIsBeingEdited } from '../../stores/MessageActionStore';
+import {
+ useIsInfoMessage,
+ useMessageField,
+ useMessageLongPress,
+ useMessagePress,
+ useMessageStatus,
+ useMessageTouchable
+} from '../../stores/MessageStore';
+
+const MessageTouchable = (props: TMessageProps) => {
+ 'use memo';
+
+ const { colors } = useTheme();
+ const { ref: touchRef, markAsLastFocused } = useLastFocusedMessageRef();
+ const isInfo = useIsInfoMessage();
+ const { hasError } = useMessageStatus();
+ const { tappable } = useMessageTouchable();
+ const id = useMessageField(item => item.id);
+ const isBeingEdited = useIsBeingEdited(id);
+ const onPressAction = useMessagePress();
+ const onLongPress = useMessageLongPress();
+ const accessibilityLabelValue = useMessageAccessibilityLabel();
+ const accessibilityActions = useMessageAccessibilityActions(!tappable);
+ const accessibilityHint = useMessageAccessibilityHint();
+
+ let backgroundColor = undefined;
+ if (isBeingEdited) {
+ backgroundColor = colors.statusBackgroundWarning2;
+ }
+ if (props.highlighted) {
+ backgroundColor = colors.surfaceNeutral;
+ }
+
+ if (hasError || isInfo) {
+ return (
+
+
+
+ );
+ }
+
+ const handleLongPress = () => {
+ markAsLastFocused();
+ onLongPress();
+ };
+
+ return (
+
+
+ {
+ if (e.nativeEvent.actionName === 'showActions') handleLongPress();
+ }}>
+
+
+
+
+ );
+};
+
+export default MessageTouchable;
diff --git a/app/containers/message/Touch.tsx b/app/containers/message/components/Touchable/Touch.tsx
similarity index 96%
rename from app/containers/message/Touch.tsx
rename to app/containers/message/components/Touchable/Touch.tsx
index 882cb7a7c51..e405384a8f0 100644
--- a/app/containers/message/Touch.tsx
+++ b/app/containers/message/components/Touchable/Touch.tsx
@@ -12,8 +12,8 @@ import {
} from 'react-native';
import { withKeyboardFocus } from 'react-native-external-keyboard';
-import { useTheme } from '../../theme';
-import { isIOS } from '../../lib/methods/helpers';
+import { useTheme } from '../../../../theme';
+import { isIOS } from '../../../../lib/methods/helpers';
export interface ITouchProps extends TouchableWithoutFeedbackProps {
children: ReactNode;
@@ -51,6 +51,8 @@ const Touch = forwardRef(
},
ref
) => {
+ 'use memo';
+
const { colors } = useTheme();
// The background color must be applied to the RectButton, not the View.
// If set on the View, the touch opacity animation won't work properly.
diff --git a/app/containers/message/Urls.tsx b/app/containers/message/components/Urls.tsx
similarity index 69%
rename from app/containers/message/Urls.tsx
rename to app/containers/message/components/Urls.tsx
index 3bdf1d3d653..ec42868cfa7 100644
--- a/app/containers/message/Urls.tsx
+++ b/app/containers/message/components/Urls.tsx
@@ -1,21 +1,21 @@
-import { useCallback, useContext, useEffect, useLayoutEffect, useState, memo, type ReactElement } from 'react';
+import { useContext, useEffect, useLayoutEffect, useState, type ReactElement } from 'react';
import { StyleSheet, Text, View, type ViewStyle } from 'react-native';
import Clipboard from '@react-native-clipboard/clipboard';
import { Image } from 'expo-image';
-import { dequal } from 'dequal';
import axios from 'axios';
-import { useAppSelector } from '../../lib/hooks/useAppSelector';
-import Touchable from './Touchable';
-import openLink from '../../lib/methods/helpers/openLink';
-import sharedStyles from '../../views/Styles';
-import { useTheme } from '../../theme';
-import { LISTENER } from '../Toast';
-import EventEmitter from '../../lib/methods/helpers/events';
-import I18n from '../../i18n';
-import MessageContext from './Context';
-import { type IUrl } from '../../definitions';
-import { WidthAwareContext } from './Components/WidthAwareView';
+import MessageActionTouchable from './Touchable/MessageActionTouchable';
+import openLink from '../../../lib/methods/helpers/openLink';
+import sharedStyles from '../../../views/Styles';
+import { useTheme } from '../../../theme';
+import { LISTENER } from '../../Toast';
+import EventEmitter from '../../../lib/methods/helpers/events';
+import I18n from '../../../i18n';
+import { type IUrl } from '../../../definitions';
+import { WidthAwareContext } from './WidthAwareView';
+import { useUrls } from '../stores/MessageStore';
+import { useBaseUrl, useMessageUser } from '../stores/MessageRoomStore';
+import { useSetting } from '../../../lib/hooks/useSetting';
const styles = StyleSheet.create({
container: {
@@ -121,23 +121,20 @@ const Url = ({ url }: { url: IUrl }) => {
'use memo';
const { colors, theme } = useTheme();
- const { baseUrl, user } = useContext(MessageContext);
- const API_Embed = useAppSelector(state => state.settings.API_Embed);
+ const baseUrl = useBaseUrl();
+ const user = useMessageUser();
+ const API_Embed = useSetting('API_Embed') as boolean;
const [imageUrl, setImageUrl] = useState(null);
- const getImageUrl = useCallback(() => {
- const _imageUrl = url.image || url.url;
-
- if (!_imageUrl) return null;
- if (_imageUrl.startsWith('http')) return _imageUrl;
- return `${baseUrl}/${_imageUrl}?rc_uid=${user.id}&rc_token=${user.token}`;
- }, [url.image, url.url, baseUrl, user.id, user.token]);
-
useEffect(() => {
const verifyUrlIsImage = async () => {
try {
- const _imageUrl = getImageUrl();
- if (!_imageUrl || !API_Embed) return;
+ const rawImageUrl = url.image || url.url;
+ if (!rawImageUrl || !API_Embed) return;
+
+ const _imageUrl = rawImageUrl.startsWith('http')
+ ? rawImageUrl
+ : `${baseUrl}/${rawImageUrl}?rc_uid=${user?.id ?? ''}&rc_token=${user?.token ?? ''}`;
const response = await axios.head(_imageUrl);
const contentType = response.headers['content-type'];
@@ -149,7 +146,7 @@ const Url = ({ url }: { url: IUrl }) => {
}
};
verifyUrlIsImage();
- }, [url.image, url.url, API_Embed, getImageUrl]);
+ }, [url.image, url.url, API_Embed, baseUrl, user?.id, user?.token]);
const onPress = () => openLink(url.url, theme);
@@ -165,7 +162,7 @@ const Url = ({ url }: { url: IUrl }) => {
}
return (
- {
{imageUrl ? : null}
{hasContent ? : null}
>
-
+
);
};
-const Urls = memo(
- ({ urls }: { urls?: IUrl[] }): ReactElement[] | null => {
- 'use memo';
+const Urls = (): ReactElement[] | null => {
+ 'use memo';
- if (!urls || urls.length === 0) {
- return null;
- }
+ const urls = useUrls();
- return urls.map((url: IUrl) => );
- },
- (oldProps, newProps) => dequal(oldProps.urls, newProps.urls)
-);
+ if (!urls || urls.length === 0) {
+ return null;
+ }
-UrlContent.displayName = 'MessageUrlContent';
-UrlImage.displayName = 'MessageUrlImage';
-Url.displayName = 'MessageUrl';
-Urls.displayName = 'MessageUrls';
+ return urls.map((url: IUrl) => );
+};
export default Urls;
diff --git a/app/containers/message/components/User.tsx b/app/containers/message/components/User.tsx
new file mode 100644
index 00000000000..ad6b214b02a
--- /dev/null
+++ b/app/containers/message/components/User.tsx
@@ -0,0 +1,97 @@
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+
+import { type MessageTypesValues, SubscriptionType } from '../../../definitions';
+import { useTheme } from '../../../theme';
+import sharedStyles from '../../../views/Styles';
+import RightIcons from './RightIcons';
+import { messageHaveAuthorName } from '../utils';
+import MessageTime from './Time';
+import { useResponsiveLayout } from '../../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
+import { useSetting } from '../../../lib/hooks/useSetting';
+import { useIsOwnMessage, useMessageAuthor, useMessageGrouping, useMessageHeaderMeta } from '../stores/MessageStore';
+import { useNavToRoomInfo } from '../stores/MessageRoomStore';
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center'
+ },
+ username: {
+ flexShrink: 1,
+ fontSize: 16,
+ lineHeight: 22,
+ ...sharedStyles.textSemibold
+ },
+ usernameInfoMessage: {
+ fontSize: 16,
+ ...sharedStyles.textMedium
+ },
+ titleContainer: {
+ flexShrink: 1,
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 4
+ },
+ alias: {
+ fontSize: 14,
+ ...sharedStyles.textRegular
+ }
+});
+
+const User = () => {
+ 'use memo';
+
+ const useRealName = useSetting('UI_Use_Real_Name') as boolean;
+ const navToRoomInfo = useNavToRoomInfo();
+ const { colors } = useTheme();
+ const { isLargeFontScale } = useResponsiveLayout();
+ const isHeader = useMessageGrouping();
+ const { u: author, alias } = useMessageAuthor();
+ const { t: type } = useMessageHeaderMeta();
+ const itsMe = useIsOwnMessage();
+
+ if (isHeader) {
+ const username = (useRealName && author?.name) || author?.username;
+ const aliasUsername = alias ? @{username} : null;
+
+ const onUserPress = () => {
+ navToRoomInfo?.({
+ t: SubscriptionType.DIRECT,
+ rid: author?._id || '',
+ itsMe
+ });
+ };
+
+ const textContent = (
+ <>
+ {alias || username}
+ {aliasUsername}
+ >
+ );
+
+ if (messageHaveAuthorName(type as MessageTypesValues)) {
+ return (
+
+ {textContent}
+
+ );
+ }
+
+ return (
+
+
+
+ {textContent}
+
+ {isLargeFontScale ? null : }
+
+
+
+ );
+ }
+ return null;
+};
+
+export default User;
diff --git a/app/containers/message/Components/WidthAwareView.tsx b/app/containers/message/components/WidthAwareView.tsx
similarity index 100%
rename from app/containers/message/Components/WidthAwareView.tsx
rename to app/containers/message/components/WidthAwareView.tsx
diff --git a/app/containers/message/Components/Attachments/utils.test.ts b/app/containers/message/components/__tests__/Attachments.utils.test.ts
similarity index 97%
rename from app/containers/message/Components/Attachments/utils.test.ts
rename to app/containers/message/components/__tests__/Attachments.utils.test.ts
index d6b042dcdca..8f29a4627f0 100644
--- a/app/containers/message/Components/Attachments/utils.test.ts
+++ b/app/containers/message/components/__tests__/Attachments.utils.test.ts
@@ -1,5 +1,5 @@
import { type IAttachment } from '../../../../definitions';
-import { isContentAttachment, isQuoteAttachment } from './utils';
+import { isContentAttachment, isQuoteAttachment } from '../Attachments/utils';
describe('Attachments predicates', () => {
const quote: IAttachment = { author_name: 'rocket.cat', ts: new Date(), text: 'quoted message' };
diff --git a/app/containers/message/components/__tests__/Blocks.test.tsx b/app/containers/message/components/__tests__/Blocks.test.tsx
new file mode 100644
index 00000000000..4c1b7595635
--- /dev/null
+++ b/app/containers/message/components/__tests__/Blocks.test.tsx
@@ -0,0 +1,80 @@
+import { Provider } from 'react-redux';
+import { render } from '@testing-library/react-native';
+
+import Blocks from '../Blocks';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+
+jest.mock('../../../UIKit/MessageBlock', () => ({
+ messageBlockWithContext: jest.fn(() => () => null)
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-var-requires
+const { messageBlockWithContext } = jest.requireMock('../../../UIKit/MessageBlock');
+
+const buildItem = (blocks: TAnyMessageModel['blocks']) => ({ id: 'msg-1', blocks } as unknown as TAnyMessageModel);
+
+const renderBlocks = (blocks: TAnyMessageModel['blocks'], config: Partial = {}) =>
+ render(
+
+
+
+
+
+
+
+ );
+
+describe('Blocks', () => {
+ beforeEach(() => {
+ messageBlockWithContext.mockClear();
+ });
+
+ it('renders null and skips messageBlockWithContext when blocks is null', () => {
+ const { toJSON } = renderBlocks(null);
+ expect(toJSON()).toBeNull();
+ expect(messageBlockWithContext).not.toHaveBeenCalled();
+ });
+
+ it('renders null and skips messageBlockWithContext when blocks is empty', () => {
+ const { toJSON } = renderBlocks([]);
+ expect(toJSON()).toBeNull();
+ expect(messageBlockWithContext).not.toHaveBeenCalled();
+ });
+
+ it('wires appId from the first block and forwards rid', () => {
+ renderBlocks([{ appId: 'app-1' }] as TAnyMessageModel['blocks'], { rid: 'room-1' });
+ expect(messageBlockWithContext).toHaveBeenCalledWith(expect.objectContaining({ appId: 'app-1', rid: 'room-1' }));
+ });
+
+ it('falls back appId to an empty string when the first block has none', () => {
+ renderBlocks([{}] as TAnyMessageModel['blocks']);
+ expect(messageBlockWithContext).toHaveBeenCalledWith(expect.objectContaining({ appId: '' }));
+ });
+
+ 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 });
+
+ const { action } = messageBlockWithContext.mock.calls[0][0];
+ await action({ actionId: 'submit', value: 'v', blockId: 'block-1' });
+
+ expect(blockAction).toHaveBeenCalledWith({
+ actionId: 'submit',
+ appId: 'app-1',
+ value: 'v',
+ blockId: 'block-1',
+ rid: '',
+ mid: 'msg-1'
+ });
+ });
+
+ it('no-ops without throwing when blockAction is undefined', async () => {
+ renderBlocks([{ appId: 'app-1' }] as TAnyMessageModel['blocks']);
+
+ const { action } = messageBlockWithContext.mock.calls[0][0];
+ await expect(action({ actionId: 'submit', value: 'v', blockId: 'block-1' })).resolves.toBeUndefined();
+ });
+});
diff --git a/app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.test.tsx b/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx
similarity index 83%
rename from app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.test.tsx
rename to app/containers/message/components/__tests__/CollapsibleQuote.test.tsx
index 651c59c02fa..c2f1a9ba02a 100644
--- a/app/containers/message/Components/Attachments/CollapsibleQuote/CollapsibleQuote.test.tsx
+++ b/app/containers/message/components/__tests__/CollapsibleQuote.test.tsx
@@ -1,10 +1,12 @@
import { fireEvent, render, within } from '@testing-library/react-native';
import { Provider } from 'react-redux';
-import { setUser } from '../../../../../actions/login';
-import { mockedStore } from '../../../../../reducers/mockedStore';
-import MessageContext from '../../../Context';
-import CollapsibleQuote from '.';
+import { setUser } from '../../../../actions/login';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import CollapsibleQuote from '../Attachments/CollapsibleQuote';
const testAttachment = {
ts: '1970-01-01T00:00:00.000Z',
@@ -35,15 +37,20 @@ const initialMockedStoreState = () => {
initialMockedStoreState();
+const contextValue: Partial = {
+ user: { username: 'Marcos' },
+ timeFormat: 'LT'
+};
+
+const item = { id: 'collapsible-quote-msg' } as unknown as TAnyMessageModel;
+
const Render = () => (
- {},
- user: { username: 'Marcos' }
- }}>
-
-
+
+
+
+
+
);
diff --git a/app/containers/message/Content.test.tsx b/app/containers/message/components/__tests__/Content.test.tsx
similarity index 71%
rename from app/containers/message/Content.test.tsx
rename to app/containers/message/components/__tests__/Content.test.tsx
index d9175bee6f2..6289edc307c 100644
--- a/app/containers/message/Content.test.tsx
+++ b/app/containers/message/components/__tests__/Content.test.tsx
@@ -1,11 +1,13 @@
import { Provider } from 'react-redux';
import { render } from '@testing-library/react-native';
-import Content from './Content';
-import MessageContext from './Context';
-import { setUser } from '../../actions/login';
-import { mockedStore } from '../../reducers/mockedStore';
-import { type IMessageContent } from './interfaces';
+import Content from '../Content';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { setUser } from '../../../../actions/login';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type IAttachment, type TAnyMessageModel } from '../../../../definitions';
+import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../../lib/constants/keys';
mockedStore.dispatch(
setUser({
@@ -17,44 +19,56 @@ mockedStore.dispatch(
})
);
-const baseProps: Partial = {
- _id: 'msg-1',
- isTemp: false,
- isInfo: false,
- isThreadRoom: false,
- isEdited: false,
- isEncrypted: false,
- isIgnored: false,
- isTranslated: false,
- isHeader: false,
- hasError: false,
- navToRoomInfo: jest.fn()
+type TOverrides = {
+ attachments?: IAttachment[];
+ isEncrypted?: boolean;
+ msg?: string;
+ _id?: string;
+ isTemp?: boolean;
+ isInfo?: boolean;
+ isEdited?: boolean;
+ isTranslated?: boolean;
+ isHeader?: boolean;
+ hasError?: boolean;
+ autoTranslateLanguage?: string;
+ tmid?: string;
+ isIgnored?: boolean;
};
-const renderContent = (overrides: Partial & { attachments?: any[]; autoTranslateLanguage?: string }) =>
- render(
+const buildItem = (
+ msg: string | undefined,
+ attachments: IAttachment[] | undefined,
+ isEncrypted: boolean | undefined,
+ tmid: string | undefined
+) =>
+ ({
+ id: 'msg-1',
+ msg,
+ attachments,
+ t: isEncrypted ? E2E_MESSAGE_TYPE : undefined,
+ e2e: isEncrypted ? E2E_STATUS.PENDING : undefined,
+ tmid
+ } as unknown as TAnyMessageModel);
+
+const tree = (overrides: TOverrides) => {
+ const { msg, attachments, isEncrypted, autoTranslateLanguage, tmid, isIgnored } = overrides;
+ const contextValue: Partial = {
+ user: { username: 'john' },
+ navToRoomInfo: jest.fn(),
+ autoTranslateLanguage
+ };
+ return (
-
-
-
+
+
+
+
+
);
+};
-const tree = (overrides: Partial & { attachments?: any[]; autoTranslateLanguage?: string }) => (
-
-
-
-
-
-);
+const renderContent = (overrides: TOverrides) => render(tree(overrides));
describe('Content preview branch — Thread Message Attachment fallback', () => {
test('renders the Attachment title as a preview line when msg is empty and an image-type Attachment has only a title', () => {
diff --git a/app/containers/message/Components/Attachments/Image/Image.test.tsx b/app/containers/message/components/__tests__/Image.test.tsx
similarity index 81%
rename from app/containers/message/Components/Attachments/Image/Image.test.tsx
rename to app/containers/message/components/__tests__/Image.test.tsx
index d23d6b3166b..41b29cd85ce 100644
--- a/app/containers/message/Components/Attachments/Image/Image.test.tsx
+++ b/app/containers/message/components/__tests__/Image.test.tsx
@@ -1,10 +1,10 @@
import { render, waitFor } from '@testing-library/react-native';
import { Image as ExpoImage } from 'expo-image';
-import { WidthAwareContext } from '../../WidthAwareView';
-import { MessageImage } from './Image';
+import { WidthAwareContext } from '../WidthAwareView';
+import { MessageImage } from '../Attachments/Image/Image';
-jest.mock('../../../../../lib/methods/userPreferences', () => ({
+jest.mock('../../../../lib/methods/userPreferences', () => ({
useUserPreferences: jest.fn(() => [true, jest.fn()])
}));
diff --git a/app/containers/message/Components/Attachments/Image/Container.test.tsx b/app/containers/message/components/__tests__/ImageContainer.test.tsx
similarity index 62%
rename from app/containers/message/Components/Attachments/Image/Container.test.tsx
rename to app/containers/message/components/__tests__/ImageContainer.test.tsx
index c0fe3978f2c..0483324a1c5 100644
--- a/app/containers/message/Components/Attachments/Image/Container.test.tsx
+++ b/app/containers/message/components/__tests__/ImageContainer.test.tsx
@@ -1,18 +1,22 @@
import { render } from '@testing-library/react-native';
import { type ComponentProps } from 'react';
import { A11y } from 'react-native-a11y-order';
+import { Provider } from 'react-redux';
-import MessageContext from '../../../Context';
-import ImageContainer from './Container';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import ImageContainer from '../Attachments/Image/Container';
-jest.mock('../../../../markdown', () => {
+jest.mock('../../../markdown', () => {
const React = require('react');
const { Text } = require('react-native');
return ({ msg }: { msg?: string }) => {msg};
});
-jest.mock('../../../hooks/useMediaAutoDownload', () => ({
+jest.mock('../../hooks/useMediaAutoDownload', () => ({
useMediaAutoDownload: jest.fn(() => ({
status: 'downloaded',
onPress: jest.fn(),
@@ -21,30 +25,34 @@ jest.mock('../../../hooks/useMediaAutoDownload', () => ({
}))
}));
-jest.mock('./Image', () => ({
+jest.mock('../Attachments/Image/Image', () => ({
MessageImage: () => null
}));
const mockUseAltTextSupported = jest.fn();
-jest.mock('../../../../../lib/hooks/useAltTextSupported', () => ({
+jest.mock('../../../../lib/hooks/useAltTextSupported', () => ({
useAltTextSupported: () => mockUseAltTextSupported()
}));
-const renderImageContainer = (props?: Partial>) =>
- render(
-
-
-
-
-
+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 item = { id } as unknown as TAnyMessageModel;
+ return render(
+
+
+
+
+
+
+
+
+
);
+};
describe('ImageContainer', () => {
beforeEach(() => {
diff --git a/app/containers/message/components/__tests__/JitsiLayout.test.tsx b/app/containers/message/components/__tests__/JitsiLayout.test.tsx
new file mode 100644
index 00000000000..bfaae1594a8
--- /dev/null
+++ b/app/containers/message/components/__tests__/JitsiLayout.test.tsx
@@ -0,0 +1,37 @@
+import { Provider } from 'react-redux';
+import { render } from '@testing-library/react-native';
+
+import JitsiLayout from '../Layout/JitsiLayout';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import I18n from '../../../../i18n';
+
+const item = {
+ id: 'msg-1',
+ msg: '',
+ t: 'jitsi_call_started',
+ u: { _id: 'author-id', username: 'diego.mello' }
+} as unknown as TAnyMessageModel;
+
+const renderJitsiLayout = () => {
+ const room: Partial = { handleEnterCall: jest.fn() };
+ return render(
+
+
+
+
+
+
+
+ );
+};
+
+describe('JitsiLayout', () => {
+ test('renders the localized "started a call" line AND the join button for a jitsi_call_started message', () => {
+ const { getByText } = renderJitsiLayout();
+ expect(getByText(I18n.t('Started_call', { userBy: 'diego.mello' }))).toBeTruthy();
+ expect(getByText(I18n.t('Click_to_join'))).toBeTruthy();
+ });
+});
diff --git a/app/containers/message/components/__tests__/Message.test.tsx b/app/containers/message/components/__tests__/Message.test.tsx
new file mode 100644
index 00000000000..743b38efa4f
--- /dev/null
+++ b/app/containers/message/components/__tests__/Message.test.tsx
@@ -0,0 +1,12 @@
+import { generateSnapshots } from '../../../../../.rnstorybook/generateSnapshots';
+import * as stories from '../stories/Message.stories';
+
+jest.mock('../../../../lib/methods/handleMediaDownload.ts', () => ({
+ downloadMediaFile: jest.fn(),
+ getMediaCache: jest.fn(),
+ isDownloadActive: jest.fn()
+}));
+
+jest.unmock('../../../../lib/hooks/useResponsiveLayout/useResponsiveLayout');
+
+generateSnapshots(stories);
diff --git a/app/containers/message/components/__tests__/Preview.test.tsx b/app/containers/message/components/__tests__/Preview.test.tsx
new file mode 100644
index 00000000000..e6a55125c9b
--- /dev/null
+++ b/app/containers/message/components/__tests__/Preview.test.tsx
@@ -0,0 +1,48 @@
+import { Provider } from 'react-redux';
+import { render } from '@testing-library/react-native';
+
+import MessagePreview from '../Preview';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+
+jest.mock('../Touchable/Touch', () => {
+ const { forwardRef } = require('react');
+ const { TouchableOpacity } = require('react-native');
+ return forwardRef(({ children, onPress, onLongPress, ...props }: any, ref: any) => (
+
+ {children}
+
+ ));
+});
+
+const mockMessage = {
+ id: 'msg-1',
+ rid: 'room-1',
+ msg: 'Hello World',
+ u: { _id: 'user-1', username: 'testuser', name: 'Test User' },
+ ts: new Date(),
+ experimentalSubscribe: jest.fn(() => jest.fn())
+} as unknown as TAnyMessageModel;
+
+// MessagePreview renders as the only surface outside RoomView/MessagesView/SearchMessagesView.
+// The message subtree's room hooks throw when mounted without a MessageRoomProvider, so it must self-provide.
+describe('MessagePreview', () => {
+ it('renders without a MessageRoomProvider crash', () => {
+ expect(() =>
+ render(
+
+
+
+ )
+ ).not.toThrow();
+ });
+
+ it('renders the message text', () => {
+ const { getByText } = render(
+
+
+
+ );
+ expect(getByText('Hello World')).toBeTruthy();
+ });
+});
diff --git a/app/containers/message/components/__tests__/Quote.test.tsx b/app/containers/message/components/__tests__/Quote.test.tsx
new file mode 100644
index 00000000000..d565fcfc5d6
--- /dev/null
+++ b/app/containers/message/components/__tests__/Quote.test.tsx
@@ -0,0 +1,97 @@
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+import Quote from '../Attachments/Quote';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type IAttachment, type TAnyMessageModel } from '../../../../definitions';
+import { fileDownloadAndPreview } from '../../../../lib/methods/helpers';
+
+jest.mock('../../../markdown', () => {
+ const React = require('react');
+ const { Text } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ msg }: { msg?: string }) => {msg},
+ MarkdownPreview: ({ msg }: { msg?: string }) => {msg}
+ };
+});
+
+// Never resolves, so a click leaves the reply's loading state stuck at `true`.
+jest.mock('../../../../lib/methods/helpers', () => ({
+ fileDownloadAndPreview: jest.fn(() => new Promise(() => {}))
+}));
+
+jest.mock('../../../../lib/methods/helpers/openLink', () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+jest.mock('../../../../lib/methods/helpers/formatAttachmentUrl', () => ({
+ formatAttachmentUrl: jest.fn((url: string) => `formatted:${url}`)
+}));
+
+jest.mock('expo-image', () => {
+ const { View } = require('react-native');
+ const Image = () => ;
+ Image.loadAsync = jest.fn();
+ return { Image };
+});
+
+const mockFileDownloadAndPreview = fileDownloadAndPreview as jest.Mock;
+
+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
+ };
+ return render(
+
+
+
+
+
+
+
+ );
+};
+
+describe('Quote', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('keeps per-attachment loading state pinned to its own reply when the quotes are reordered', async () => {
+ const attachmentA: IAttachment = { type: 'file', title_link: 'file-a', text: 'A', author_name: 'Author' };
+ const attachmentB: IAttachment = { type: 'file', title_link: 'file-b', text: 'B', author_name: 'Author' };
+
+ const { getByTestId, rerender } = renderQuote([attachmentA, attachmentB]);
+
+ fireEvent.press(getByTestId('reply-Author-A'));
+ await waitFor(() => expect(mockFileDownloadAndPreview).toHaveBeenCalled());
+ expect(getByTestId('reply-Author-A').props.accessibilityState.disabled).toBe(true);
+ expect(getByTestId('reply-Author-B').props.accessibilityState.disabled).toBe(false);
+
+ rerender(
+
+
+
+
+
+
+
+ );
+
+ // Reordering must not transfer attachment A's stuck loading state onto attachment B.
+ expect(getByTestId('reply-Author-B').props.accessibilityState.disabled).toBe(false);
+ expect(getByTestId('reply-Author-A').props.accessibilityState.disabled).toBe(true);
+ });
+});
diff --git a/app/containers/message/components/__tests__/Reactions.test.tsx b/app/containers/message/components/__tests__/Reactions.test.tsx
new file mode 100644
index 00000000000..33ad050df72
--- /dev/null
+++ b/app/containers/message/components/__tests__/Reactions.test.tsx
@@ -0,0 +1,113 @@
+import { render, fireEvent } from '@testing-library/react-native';
+
+import Reactions from '../Reactions';
+import { MessageProviders } from '../../__tests__/testHelpers';
+import { setUser } from '../../../../actions/login';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type IReaction, type TAnyMessageModel } from '../../../../definitions';
+
+const initialMockedStoreState = () => {
+ mockedStore.dispatch(
+ setUser({
+ settings: {
+ preferences: {
+ convertAsciiEmoji: true
+ }
+ }
+ })
+ );
+};
+
+initialMockedStoreState();
+
+type FakeItem = TAnyMessageModel & { reactions: IReaction[]; _emit: () => void };
+
+// Mirrors the MessageStore.test.tsx fake-model pattern: a stable item mutated in place,
+// with experimentalSubscribe/_emit standing in for WatermelonDB's reactivity.
+const buildItem = (reactions: IReaction[]): FakeItem => {
+ const subscribers: Array<() => void> = [];
+ return {
+ id: 'msg-1',
+ reactions,
+ experimentalSubscribe(cb: () => void) {
+ subscribers.push(cb);
+ return () => {
+ const idx = subscribers.indexOf(cb);
+ if (idx !== -1) subscribers.splice(idx, 1);
+ };
+ },
+ _emit() {
+ subscribers.forEach(cb => cb());
+ }
+ } as unknown as FakeItem;
+};
+
+// onReactionPress is built outside the component (test scope) and mutates `item` in
+// place, mirroring how a real WatermelonDB write would be observed via experimentalSubscribe.
+const buildOnReactionPress = (item: FakeItem) => (emoji: string) => {
+ item.reactions = item.reactions.filter(r => r.emoji !== emoji);
+ item._emit();
+};
+
+const TestWrapper = ({ item, onReactionPress }: { item: FakeItem; onReactionPress: (emoji: string) => void }) => (
+
+
+
+);
+
+it('renders all reactions and AddReaction button', () => {
+ const reactions: IReaction[] = [
+ { _id: '1', emoji: '👍', usernames: ['john', 'alice'], names: [] },
+ { _id: '2', emoji: '😂', usernames: ['bob'], names: [] }
+ ];
+ const item = buildItem(reactions);
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('message-reaction-👍')).toBeTruthy();
+ expect(getByTestId('message-reaction-😂')).toBeTruthy();
+ expect(getByTestId('message-add-reaction')).toBeTruthy();
+});
+
+it('should render unicode emoji reaction', () => {
+ const reactions = [{ _id: '1', emoji: ':)', usernames: ['john', 'alice'], names: [] }];
+ const item = buildItem(reactions);
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('message-reaction-:)')).toBeTruthy();
+ expect(getByTestId('message-add-reaction')).toBeTruthy();
+});
+
+it('should render custom emoji reaction', () => {
+ const reactions = [{ _id: '1', emoji: ':aaaaa:', usernames: ['john', 'alice'], names: [] }];
+ const item = buildItem(reactions);
+
+ const { getByTestId } = render();
+
+ expect(getByTestId('message-reaction-:aaaaa:')).toBeTruthy();
+ expect(getByTestId('message-add-reaction')).toBeTruthy();
+});
+
+it('should remove reaction', () => {
+ const reactions = [
+ { _id: '1', emoji: ':thumbsup:', usernames: ['john'], names: [] },
+ { _id: '1', emoji: ':heart_eyes:', usernames: ['john', 'alice'], names: [] }
+ ];
+ const item = buildItem(reactions);
+
+ const { getByTestId, queryByTestId } = render();
+
+ expect(getByTestId('message-reaction-:thumbsup:')).toBeTruthy();
+ expect(getByTestId('message-reaction-:heart_eyes:')).toBeTruthy();
+ fireEvent.press(getByTestId('message-reaction-:thumbsup:'));
+ expect(queryByTestId('message-reaction-:thumbsup:')).toBeNull();
+ expect(getByTestId('message-add-reaction')).toBeTruthy();
+});
diff --git a/app/containers/message/components/__tests__/RepliedThread.test.tsx b/app/containers/message/components/__tests__/RepliedThread.test.tsx
new file mode 100644
index 00000000000..ec30e1cbb1d
--- /dev/null
+++ b/app/containers/message/components/__tests__/RepliedThread.test.tsx
@@ -0,0 +1,84 @@
+import { render, waitFor } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+import RepliedThread from '../RepliedThread';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { mockedStore } from '../../../../reducers/mockedStore';
+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 = {}) =>
+ render(
+
+
+
+
+
+
+
+ );
+
+describe('RepliedThread', () => {
+ test('renders the encrypted placeholder when the message is encrypted, ignoring tmsg', () => {
+ const item = buildItem({ t: E2E_MESSAGE_TYPE, e2e: E2E_STATUS.PENDING, tmsg: 'plaintext should not show' });
+ const { getByTestId, queryByTestId } = renderRepliedThread(item);
+
+ expect(getByTestId('message-thread-replied-on-Encrypted message')).toBeTruthy();
+ expect(queryByTestId('message-thread-replied-on-plaintext should not show')).toBeNull();
+ });
+
+ 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 });
+
+ await waitFor(() => expect(getByTestId('message-thread-replied-on-fetched thread name')).toBeTruthy());
+ expect(fetchThreadName).toHaveBeenCalledWith('thread1', 'msg1');
+ });
+
+ 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 });
+
+ await waitFor(() => expect(getByTestId('message-thread-replied-on-first thread name')).toBeTruthy());
+
+ const updatedItem = buildItem({ tmid: 'thread2', id: 'msg2', tmsg: undefined });
+ rerender(
+
+
+
+
+
+
+
+ );
+
+ await waitFor(() => expect(getByTestId('message-thread-replied-on-second thread name')).toBeTruthy());
+ expect(fetchThreadName).toHaveBeenLastCalledWith('thread2', 'msg2');
+ });
+
+ test('updates the display when tmsg/isEncrypted change without remounting', () => {
+ const item = buildItem({ tmsg: 'first reply' });
+ const { getByTestId, rerender } = renderRepliedThread(item);
+
+ expect(getByTestId('message-thread-replied-on-first reply')).toBeTruthy();
+
+ const updatedItem = buildItem({ tmsg: 'second reply' });
+ rerender(
+
+
+
+
+
+
+
+ );
+
+ expect(getByTestId('message-thread-replied-on-second reply')).toBeTruthy();
+ });
+});
diff --git a/app/containers/message/components/__tests__/Reply.test.tsx b/app/containers/message/components/__tests__/Reply.test.tsx
new file mode 100644
index 00000000000..28ab3b8ff79
--- /dev/null
+++ b/app/containers/message/components/__tests__/Reply.test.tsx
@@ -0,0 +1,206 @@
+import { fireEvent, render, waitFor } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+import Reply from '../Attachments/Reply';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type IAttachment, type TAnyMessageModel } from '../../../../definitions';
+import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../../lib/constants/keys';
+import { fileDownloadAndPreview } from '../../../../lib/methods/helpers';
+import openLink from '../../../../lib/methods/helpers/openLink';
+import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl';
+
+jest.mock('../../../markdown', () => {
+ const React = require('react');
+ const { Text } = require('react-native');
+ return {
+ __esModule: true,
+ default: ({ msg }: { msg?: string }) => {msg},
+ MarkdownPreview: ({ msg }: { msg?: string }) => {msg}
+ };
+});
+
+jest.mock('../../../../lib/methods/helpers', () => ({
+ fileDownloadAndPreview: jest.fn(() => Promise.resolve())
+}));
+
+jest.mock('../../../../lib/methods/helpers/openLink', () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+jest.mock('../../../../lib/methods/helpers/formatAttachmentUrl', () => ({
+ formatAttachmentUrl: jest.fn((url: string) => `formatted:${url}`)
+}));
+
+jest.mock('expo-image', () => {
+ const { View } = require('react-native');
+ const Image = () => ;
+ Image.loadAsync = jest.fn();
+ return { Image };
+});
+
+const mockFileDownloadAndPreview = fileDownloadAndPreview as jest.Mock;
+const mockOpenLink = openLink as jest.Mock;
+const mockFormatAttachmentUrl = formatAttachmentUrl as jest.Mock;
+
+const buildItem = (isEncrypted?: boolean) =>
+ ({
+ id: 'msg-1',
+ t: isEncrypted ? E2E_MESSAGE_TYPE : undefined,
+ e2e: isEncrypted ? E2E_STATUS.PENDING : undefined
+ } as unknown as TAnyMessageModel);
+
+const renderReply = ({
+ attachment,
+ msg,
+ isEncrypted,
+ ctx = {}
+}: {
+ attachment?: IAttachment;
+ msg?: string;
+ isEncrypted?: boolean;
+ ctx?: Partial;
+}) => {
+ const contextValue: Partial = {
+ user: { id: 'user-1', username: 'john', token: 'token' },
+ baseUrl: 'https://open.rocket.chat',
+ timeFormat: 'HH:mm',
+ ...ctx
+ };
+ return render(
+
+
+
+
+
+
+
+ );
+};
+
+describe('Reply', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFormatAttachmentUrl.mockImplementation((url: string) => `formatted:${url}`);
+ });
+
+ describe('null gate', () => {
+ it('renders nothing when there is no attachment', () => {
+ const { toJSON } = renderReply({ attachment: undefined });
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders nothing when the message is encrypted', () => {
+ const { toJSON } = renderReply({ attachment: { author_name: 'Alice', text: 'Hello' }, isEncrypted: true });
+ expect(toJSON()).toBeNull();
+ });
+ });
+
+ it('renders the reply with an author/text testID', () => {
+ const { getByTestId } = renderReply({ attachment: { author_name: 'Alice', text: 'Hello' } });
+ expect(getByTestId('reply-Alice-Hello')).toBeTruthy();
+ });
+
+ describe('onPress', () => {
+ it('does nothing when there is neither a title_link nor an author_link', () => {
+ const { getByTestId } = renderReply({ attachment: { author_name: 'Alice', text: 'Hello' } });
+ fireEvent.press(getByTestId('reply-Alice-Hello'));
+ expect(mockOpenLink).not.toHaveBeenCalled();
+ expect(mockFileDownloadAndPreview).not.toHaveBeenCalled();
+ });
+
+ it('downloads and previews file attachments that have a title_link', async () => {
+ const attachment: IAttachment = { type: 'file', title_link: '/file-upload/doc', text: 'doc', author_name: 'Alice' };
+ const { getByTestId } = renderReply({ attachment });
+ fireEvent.press(getByTestId('reply-Alice-doc'));
+
+ await waitFor(() => expect(mockFileDownloadAndPreview).toHaveBeenCalled());
+ expect(mockFormatAttachmentUrl).toHaveBeenCalledWith('/file-upload/doc', 'user-1', 'token', 'https://open.rocket.chat');
+ expect(mockFileDownloadAndPreview).toHaveBeenCalledWith('formatted:/file-upload/doc', attachment, 'msg-1');
+ expect(mockOpenLink).not.toHaveBeenCalled();
+ });
+
+ it('opens the link for non-file attachments using the title_link', () => {
+ const { getByTestId } = renderReply({
+ attachment: { title_link: 'https://rocket.chat', text: 'link', author_name: 'Alice' }
+ });
+ fireEvent.press(getByTestId('reply-Alice-link'));
+ expect(mockOpenLink).toHaveBeenCalledWith('https://rocket.chat', 'light');
+ expect(mockFileDownloadAndPreview).not.toHaveBeenCalled();
+ });
+
+ it('falls back to the author_link when there is no title_link', () => {
+ const { getByTestId } = renderReply({
+ attachment: { author_link: 'https://author.example', text: 'link', author_name: 'Alice' }
+ });
+ fireEvent.press(getByTestId('reply-Alice-link'));
+ expect(mockOpenLink).toHaveBeenCalledWith('https://author.example', 'light');
+ });
+ });
+
+ it('disables the touchable when the attachment is a message_link', () => {
+ const { getByTestId } = renderReply({
+ attachment: { message_link: '/msg', author_name: 'Alice', text: 'Hello' }
+ });
+ expect(getByTestId('reply-Alice-Hello').props.accessibilityState.disabled).toBe(true);
+ });
+
+ describe('Description', () => {
+ it('uses MarkdownPreview for file attachments whose title is a filename', () => {
+ const { getByTestId } = renderReply({ attachment: { type: 'file', title: 'script.py', author_name: 'Alice' } });
+ const preview = getByTestId('reply-markdown-preview');
+ expect(preview).toBeTruthy();
+ expect(preview.props.children).toBe('script.py');
+ });
+
+ it('uses Markdown when the attachment has explicit text', () => {
+ const { getByTestId } = renderReply({ attachment: { type: 'file', text: '**bold**', author_name: 'Alice' } });
+ expect(getByTestId('reply-markdown').props.children).toBe('**bold**');
+ });
+ });
+
+ describe('Title', () => {
+ it('shows the formatted time only for message_link attachments with a timestamp', () => {
+ const { getByText } = renderReply({
+ attachment: { message_link: '/msg', ts: '2021-01-01T13:45:00.000Z', author_name: 'Alice', text: 'Hi' }
+ });
+ expect(getByText('13:45')).toBeTruthy();
+ });
+
+ it('does not show a time without a message_link', () => {
+ const { queryByText } = renderReply({
+ attachment: { ts: '2021-01-01T13:45:00.000Z', author_name: 'Alice', text: 'Hi' }
+ });
+ expect(queryByText('13:45')).toBeNull();
+ });
+ });
+
+ describe('UrlImage', () => {
+ it('renders the thumbnail image when thumb_url is present', () => {
+ const { getByTestId } = renderReply({
+ attachment: { thumb_url: 'https://open.rocket.chat/thumb.png', author_name: 'Alice', text: 'Hi' }
+ });
+ expect(getByTestId('reply-url-image')).toBeTruthy();
+ });
+
+ it('does not render an image without thumb_url', () => {
+ const { queryByTestId } = renderReply({ attachment: { author_name: 'Alice', text: 'Hi' } });
+ expect(queryByTestId('reply-url-image')).toBeNull();
+ });
+ });
+
+ describe('Fields', () => {
+ it('renders attachment fields when present', () => {
+ const { getByText } = renderReply({
+ attachment: {
+ author_name: 'Alice',
+ text: 'Hi',
+ fields: [{ title: 'Status', value: 'Online', short: true }]
+ }
+ });
+ expect(getByText('Status')).toBeTruthy();
+ });
+ });
+});
diff --git a/app/containers/message/components/__tests__/Thread.test.tsx b/app/containers/message/components/__tests__/Thread.test.tsx
new file mode 100644
index 00000000000..806b86f3e02
--- /dev/null
+++ b/app/containers/message/components/__tests__/Thread.test.tsx
@@ -0,0 +1,50 @@
+import { render } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+import Thread from '../Thread';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { mockedStore } from '../../../../reducers/mockedStore';
+
+const baseContextValue: Partial = {
+ toggleFollowThread: jest.fn(),
+ user: { id: 'user1', username: 'user1' },
+ onThreadPress: jest.fn()
+};
+
+const buildItem = (overrides: Partial = {}): TAnyMessageModel =>
+ ({ id: 'msg1', msg: 'hello', tcount: 3, tlm: undefined, tmid: undefined, ...overrides } as unknown as TAnyMessageModel);
+
+const renderThread = (item: TAnyMessageModel) =>
+ render(
+
+
+
+
+
+
+
+ );
+
+describe('Thread — tlm-only update regression', () => {
+ test('renders null when tlm is undefined, then shows button after tlm arrives (same tcount)', () => {
+ const item = buildItem();
+ const { queryByTestId, rerender, getByTestId } = renderThread(item);
+
+ expect(queryByTestId('message-thread-button-hello')).toBeNull();
+
+ const updatedItem = buildItem({ tlm: new Date('2024-01-01T00:00:00Z') });
+ rerender(
+
+
+
+
+
+
+
+ );
+
+ expect(getByTestId('message-thread-button-hello')).toBeTruthy();
+ });
+});
diff --git a/app/containers/message/__snapshots__/Message.test.tsx.snap b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap
similarity index 97%
rename from app/containers/message/__snapshots__/Message.test.tsx.snap
rename to app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap
index 2f9d43e479d..4b0bda15861 100644
--- a/app/containers/message/__snapshots__/Message.test.tsx.snap
+++ b/app/containers/message/components/__tests__/__snapshots__/Message.test.tsx.snap
@@ -101,10 +101,11 @@ exports[`Story Snapshots: Archived should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-1"
>
@@ -389,6 +389,7 @@ exports[`Story Snapshots: Archived should match snapshot 1`] = `
}
>
@@ -851,6 +852,7 @@ exports[`Story Snapshots: ArchivedLargeFont should match snapshot 1`] = `
}
>
@@ -1804,10 +1806,11 @@ exports[`Story Snapshots: AttachmentWithTextAndLinkLargeFont should match snapsh
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-4"
>
@@ -2575,10 +2577,11 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-5"
>
@@ -2863,6 +2865,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
}
>
@@ -3320,6 +3323,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
}
>
@@ -3769,6 +3773,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
}
>
@@ -4234,6 +4239,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
}
>
@@ -4699,6 +4705,7 @@ exports[`Story Snapshots: Avatar should match snapshot 1`] = `
}
>
@@ -5169,6 +5176,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = `
}
>
@@ -5626,6 +5634,7 @@ exports[`Story Snapshots: Basic should match snapshot 1`] = `
}
>
@@ -6096,6 +6105,7 @@ exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = `
}
>
@@ -6553,6 +6563,7 @@ exports[`Story Snapshots: BasicLargeFont should match snapshot 1`] = `
}
>
@@ -7023,6 +7034,7 @@ exports[`Story Snapshots: BlockQuote should match snapshot 1`] = `
}
>
@@ -7513,6 +7525,7 @@ Testing block quote"
}
>
@@ -7774,10 +7787,11 @@ exports[`Story Snapshots: BlockQuoteLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-16"
>
@@ -8062,6 +8075,7 @@ exports[`Story Snapshots: BlockQuoteLargeFont should match snapshot 1`] = `
}
>
@@ -8552,6 +8566,7 @@ Testing block quote"
}
>
@@ -8813,10 +8828,11 @@ exports[`Story Snapshots: Broadcast should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-18"
>
@@ -9101,6 +9116,7 @@ exports[`Story Snapshots: Broadcast should match snapshot 1`] = `
}
>
@@ -9691,6 +9707,7 @@ exports[`Story Snapshots: BroadcastLargeFont should match snapshot 1`] = `
}
>
@@ -10281,6 +10298,7 @@ exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = `
}
>
@@ -11244,6 +11262,7 @@ exports[`Story Snapshots: CollapsedAttachments should match snapshot 1`] = `
@@ -11714,6 +11733,7 @@ exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1`
}
>
@@ -12677,6 +12697,7 @@ exports[`Story Snapshots: CollapsedAttachmentsLargeFont should match snapshot 1`
@@ -13147,6 +13168,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1`
}
>
@@ -14253,6 +14275,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithText should match snapshot 1`
@@ -14723,6 +14746,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn
}
>
@@ -15829,6 +15853,7 @@ exports[`Story Snapshots: CollapsibleAttachmentWithTextLargeFont should match sn
@@ -17243,10 +17268,11 @@ exports[`Story Snapshots: ColoredAttachmentsLargeFont should match snapshot 1`]
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-29"
>
@@ -18475,10 +18500,11 @@ exports[`Story Snapshots: CustomFields should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-30"
>
@@ -19118,6 +19143,7 @@ exports[`Story Snapshots: CustomFields should match snapshot 1`] = `
@@ -20188,6 +20214,7 @@ exports[`Story Snapshots: CustomFields should match snapshot 1`] = `
@@ -21013,6 +21040,7 @@ exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = `
@@ -22083,6 +22111,7 @@ exports[`Story Snapshots: CustomFieldsLargeFont should match snapshot 1`] = `
@@ -22553,6 +22582,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = `
}
>
@@ -23072,6 +23102,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = `
}
>
@@ -23797,6 +23830,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = `
}
>
@@ -24311,6 +24345,7 @@ exports[`Story Snapshots: DateAndUnreadSeparators should match snapshot 1`] = `
}
>
@@ -24781,6 +24816,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot
}
>
@@ -25300,6 +25336,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot
}
>
@@ -26025,6 +26064,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot
}
>
@@ -26539,6 +26579,7 @@ exports[`Story Snapshots: DateAndUnreadSeparatorsLargeFont should match snapshot
}
>
@@ -27299,10 +27340,11 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-47"
>
@@ -27879,10 +27920,11 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-48"
>
@@ -28459,10 +28500,11 @@ exports[`Story Snapshots: Discussion should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-49"
>
@@ -29052,10 +29093,11 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-50"
>
@@ -29630,10 +29671,11 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-51"
>
@@ -30210,10 +30251,11 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-52"
>
@@ -30790,10 +30831,11 @@ exports[`Story Snapshots: DiscussionLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-53"
>
@@ -31383,10 +31424,11 @@ exports[`Story Snapshots: Edited should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-54"
>
@@ -31668,7 +31709,7 @@ exports[`Story Snapshots: Edited should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Message header-edited"
>
@@ -32473,6 +32517,7 @@ exports[`Story Snapshots: EditedLargeFont should match snapshot 1`] = `
}
>
@@ -33203,6 +33251,7 @@ exports[`Story Snapshots: Editing should match snapshot 1`] = `
}
>
@@ -33673,6 +33723,7 @@ exports[`Story Snapshots: EditingLargeFont should match snapshot 1`] = `
}
>
@@ -34143,6 +34194,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -34599,6 +34651,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -35036,6 +35089,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -35586,6 +35640,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -36048,6 +36103,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -36529,6 +36585,7 @@ exports[`Story Snapshots: Emojis should match snapshot 1`] = `
}
>
@@ -37056,6 +37113,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -37512,6 +37570,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -37949,6 +38008,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -38499,6 +38559,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -38961,6 +39022,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -39442,6 +39504,7 @@ exports[`Story Snapshots: EmojisLargeFont should match snapshot 1`] = `
}
>
@@ -40041,63 +40104,29 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
-
-
-
-
- Message
-
-
-
-
+ Encrypted message
+
@@ -40150,7 +40179,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Encrypted without Header."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -40210,10 +40239,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-75"
>
-
-
-
-
- Message Encrypted without Header
-
-
-
-
+ Encrypted message
+
@@ -40446,7 +40442,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Encrypted with Reactions."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -40506,10 +40502,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-77"
>
@@ -40866,63 +40862,29 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
-
-
-
-
- Message Encrypted with Reactions
-
-
-
-
+ Encrypted message
+
- Thread with emoji :) 😂
+ Encrypted message
- Thread reply encrypted
+ Encrypted message
@@ -41843,7 +41804,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
@@ -42270,61 +42231,26 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
testID="message-content-Temp message encrypted"
>
-
-
-
-
- Temp message encrypted
-
-
-
-
+ Encrypted message
+
@@ -42377,7 +42303,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Edited encrypted."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -42437,10 +42363,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-81"
>
@@ -42797,63 +42723,29 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
-
-
-
-
- Message Edited encrypted
-
-
-
-
+ Encrypted message
+
@@ -42879,10 +42771,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-82"
>
@@ -43310,63 +43202,33 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
-
-
-
-
- This message has error and is encrypted
-
-
-
-
+ Encrypted message
+
@@ -43415,7 +43277,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Read Receipt encrypted with Header. Message was read"
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message Message was read"
accessibilityRole="button"
accessibilityState={
{
@@ -43475,10 +43337,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-83"
>
@@ -43865,63 +43727,29 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
}
>
-
-
-
-
- Read Receipt encrypted with Header
-
-
-
-
+ Encrypted message
+
@@ -43974,7 +43802,7 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Read Receipt encrypted without Header. Message was read"
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message Message was read"
accessibilityRole="button"
accessibilityState={
{
@@ -44034,10 +43862,11 @@ exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-84"
>
-
-
-
-
- Read Receipt encrypted without Header
-
-
-
-
+ Encrypted message
+
@@ -44313,7 +44108,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -44373,10 +44168,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-86"
>
@@ -44733,63 +44528,29 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Message
-
-
-
-
+ Encrypted message
+
@@ -44842,7 +44603,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Encrypted without Header."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -44902,10 +44663,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-87"
>
-
-
-
-
- Message Encrypted without Header
-
-
-
-
+ Encrypted message
+
@@ -45138,7 +44866,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Encrypted with Reactions."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -45198,10 +44926,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-89"
>
@@ -45558,63 +45286,29 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Message Encrypted with Reactions
-
-
-
-
+ Encrypted message
+
- Thread with emoji :) 😂
+ Encrypted message
- Thread reply encrypted
+ Encrypted message
@@ -46535,7 +46228,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
@@ -46962,61 +46655,26 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
testID="message-content-Temp message encrypted"
>
-
-
-
-
- Temp message encrypted
-
-
-
-
+ Encrypted message
+
@@ -47069,7 +46727,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Message Edited encrypted."
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message"
accessibilityRole="button"
accessibilityState={
{
@@ -47129,10 +46787,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-93"
>
@@ -47489,63 +47147,29 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Message Edited encrypted
-
-
-
-
+ Encrypted message
+
@@ -47571,10 +47195,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-94"
>
@@ -48002,63 +47626,33 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- This message has error and is encrypted
-
-
-
-
+ Encrypted message
+
@@ -48107,7 +47701,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Read Receipt encrypted with Header. Message was read"
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message Message was read"
accessibilityRole="button"
accessibilityState={
{
@@ -48167,10 +47761,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-95"
>
@@ -48557,63 +48151,29 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Read Receipt encrypted with Header
-
-
-
-
+ Encrypted message
+
@@ -48666,7 +48226,7 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM Read Receipt encrypted without Header. Message was read"
+ accessibilityLabel="diego.mello 10:00:00 AM Encrypted message. Encrypted message Message was read"
accessibilityRole="button"
accessibilityState={
{
@@ -48726,10 +48286,11 @@ exports[`Story Snapshots: EncryptedLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-96"
>
-
-
-
-
- Read Receipt encrypted without Header
-
-
-
-
+ Encrypted message
+
@@ -48978,10 +48505,11 @@ exports[`Story Snapshots: Error should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-98"
>
@@ -49338,6 +48865,11 @@ exports[`Story Snapshots: Error should match snapshot 1`] = `
}
>
-
-
-
-
-
-
-
-
- This message has error too
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = `
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ diego.mello
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This message has error too
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -49980,7 +49751,6 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = `
{
"color": "#6C727A",
},
- undefined,
]
}
>
@@ -49994,6 +49764,11 @@ exports[`Story Snapshots: ErrorLargeFont should match snapshot 1`] = `
}
>
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ diego.mello
+
+
+
+
+
+
+
+
+
+
+
+ 10:00 AM
+
-
-
-
-
-
-
-
@@ -50377,10 +50390,11 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`]
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-104"
>
@@ -50804,6 +50817,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`]
@@ -51400,6 +51414,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`]
@@ -51996,6 +52011,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`]
@@ -52669,6 +52685,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenames should match snapshot 1`]
@@ -53713,6 +53731,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna
@@ -54309,6 +54328,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna
@@ -54905,6 +54925,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna
@@ -55578,6 +55599,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna
@@ -56764,6 +56787,7 @@ exports[`Story Snapshots: FileAttachmentsWithFilenamesLargeFont should match sna
@@ -57234,6 +57258,7 @@ exports[`Story Snapshots: FullName should match snapshot 1`] = `
}
>
@@ -57704,6 +57729,7 @@ exports[`Story Snapshots: FullNameLargeFont should match snapshot 1`] = `
}
>
@@ -58174,6 +58200,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = `
}
>
@@ -58631,6 +58658,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = `
}
>
@@ -59536,6 +59568,7 @@ exports[`Story Snapshots: GroupedMessages should match snapshot 1`] = `
}
>
@@ -60006,6 +60039,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = `
}
>
@@ -60463,6 +60497,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = `
}
>
@@ -61368,6 +61407,7 @@ exports[`Story Snapshots: GroupedMessagesLargeFont should match snapshot 1`] = `
}
>
@@ -62492,6 +62536,7 @@ exports[`Story Snapshots: InlineKatex should match snapshot 1`] = `
}
>
@@ -62948,6 +62993,7 @@ exports[`Story Snapshots: InlineKatexLargeFont should match snapshot 1`] = `
}
>
`;
-exports[`Story Snapshots: Katex should match snapshot 1`] = `
+exports[`Story Snapshots: JitsiCall should match snapshot 1`] = `
@@ -63396,24 +63434,141 @@ exports[`Story Snapshots: Katex should match snapshot 1`] = `
}
/>
+
+ Call started by diego.mello
+
-
+
+
+
+
+ Click to join!
+
@@ -63431,7 +63586,7 @@ exports[`Story Snapshots: Katex should match snapshot 1`] = `
`;
-exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
+exports[`Story Snapshots: JitsiCallLargeFont should match snapshot 1`] = `
@@ -63570,8 +63718,8 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -63671,7 +63819,7 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -63685,19 +63833,19 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -63791,26 +63939,6 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
>
diego.mello
-
- 10:00 AM
-
+
+ Call started by diego.mello
+
-
+
+
+
+
+ Click to join!
+
+
+ 10:00 AM
+
@@ -63855,7 +64119,7 @@ exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
`;
-exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
+exports[`Story Snapshots: Katex should match snapshot 1`] = `
@@ -63994,8 +64259,8 @@ exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -64095,7 +64360,7 @@ exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -64109,19 +64374,19 @@ exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -64215,6 +64480,25 @@ exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
>
diego.mello
+
+ 10:00 AM
+
-
- 10:00 AM
-
`;
-exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
+exports[`Story Snapshots: KatexArray should match snapshot 1`] = `
@@ -64418,8 +64684,8 @@ exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -64519,7 +64785,7 @@ exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -64533,19 +64799,19 @@ exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -64639,6 +64905,25 @@ exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
>
diego.mello
+
+ 10:00 AM
+
-
- 10:00 AM
-
`;
-exports[`Story Snapshots: Lists should match snapshot 1`] = `
+exports[`Story Snapshots: KatexArrayLargeFont should match snapshot 1`] = `
@@ -64846,8 +65109,8 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -64947,7 +65210,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -64961,19 +65224,19 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -65067,26 +65330,6 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
>
diego.mello
-
- 10:00 AM
-
+
+ 10:00 AM
+
-
-
-
- •
-
-
-
-
- Dogs
-
-
-
-
-
-
- •
-
-
-
-
- cats
-
-
-
-
-
-
-
-
- •
-
-
-
-
- cats
-
-
-
-
-
+
@@ -65351,6 +65390,19 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: KatexLargeFont should match snapshot 1`] = `
+
+
@@ -65391,8 +65443,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM 1. Dogs
-2. Cats."
+ accessibilityLabel="diego.mello 10:00:00 AM \\[ \\color{black} \\colorbox{white}{ \\boxed{ \\begin{matrix} \\;\\;\\;\\; \\overlinesegment {\\underlinesegment{ \\Huge Test\\; Test \\; in \\; Test} } & \\\\ \\;\\;\\textit{develop}\\end{matrix} } }\\]."
accessibilityRole="button"
accessibilityState={
{
@@ -65452,11 +65503,11 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-142"
>
@@ -65483,8 +65534,8 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -65584,7 +65635,7 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -65598,19 +65649,19 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -65704,26 +65755,6 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
>
diego.mello
-
- 10:00 AM
-
+
+ 10:00 AM
+
-
-
-
- 1
- .
-
-
-
-
- Dogs
-
-
-
-
-
-
- 2
- .
-
-
-
-
- Cats
-
-
-
-
-
+
@@ -65940,6 +65815,19 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: Lists should match snapshot 1`] = `
+
+
@@ -65980,7 +65868,9 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM 1. Dogs."
+ accessibilityLabel="diego.mello 10:00:00 AM * Dogs
+* cats
+- cats."
accessibilityRole="button"
accessibilityState={
{
@@ -66040,10 +65930,13 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-143"
>
@@ -66328,7 +66220,10 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
}
>
- 1
- .
+ •
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ •
+
+
- 2
- .
+
+
+ cats
+
+
+
+
+
+
+ •
+
+
- Cats
+ cats
@@ -66690,13 +66460,6 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
-
@@ -66705,27 +66468,14 @@ exports[`Story Snapshots: Lists should match snapshot 1`] = `
-
-
-`;
-
-exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
-
-
@@ -66852,8 +66601,8 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -66953,7 +66702,7 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -66967,19 +66716,19 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -67073,6 +66822,25 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
>
diego.mello
+
+ 10:00 AM
+
-
- 10:00 AM
-
- •
+ 1
+ .
- Dogs
+ Dogs
@@ -67214,22 +66976,9 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"textAlign": "left",
},
{
- "color": "#2F343D",
- },
- ]
- }
- >
- •
-
-
-
-
- cats
-
-
+ 2
+ .
-
-
-
-
- •
-
-
- cats
+ Cats
@@ -67358,13 +67059,13 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
@@ -67489,8 +67189,8 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -67590,7 +67290,7 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -67604,19 +67304,19 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -67710,6 +67410,25 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
>
diego.mello
+
+ 10:00 AM
+
-
- 10:00 AM
-
- Dogs
+ Dogs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -67946,6 +67826,19 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
+
+
@@ -67986,7 +67879,9 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM 1. Dogs."
+ accessibilityLabel="diego.mello 10:00:00 AM * Dogs
+* cats
+- cats."
accessibilityRole="button"
accessibilityState={
{
@@ -68046,10 +67941,13 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-148"
>
@@ -68334,7 +68231,10 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
}
>
- 1
- .
+ •
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ •
+
+
- 2
- .
+
+
+ cats
+
+
+
+
+
+
+ •
+
+
- Cats
+ cats
@@ -68696,13 +68471,6 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
-
@@ -68711,27 +68479,14 @@ exports[`Story Snapshots: ListsLargeFont should match snapshot 1`] = `
-
-
-`;
-
-exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
-
-
@@ -68854,8 +68612,8 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -68941,7 +68699,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
/>
@@ -68955,7 +68713,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -68969,19 +68727,19 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -69052,7 +68810,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"gap": 4,
}
}
- testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername"
+ testID="username-header-diego.mello"
>
- johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
-
-
- 10:00 AM
+ diego.mello
+
+ 10:00 AM
+
-
-
+
+ 1
+ .
+
+
+
+
+ Dogs
+
+
+
+
+
+
+ 2
+ .
+
+
- this is a normal message
+
+
+ Cats
+
+
-
-
+
+
@@ -69182,13 +69070,13 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
@@ -69311,8 +69200,8 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -69398,7 +69287,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
/>
@@ -69412,7 +69301,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -69426,19 +69315,19 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -69509,7 +69398,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"gap": 4,
}
}
- testID="username-header-johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername"
+ testID="username-header-diego.mello"
>
- johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
-
-
- 10:00 AM
+ diego.mello
+
+
+ 10:00 AM
+
+
+
-
+
-
-
+ "flexDirection": "row",
+ }
+ }
+ >
+
+ 1
+ .
+
+
+
+
+ Dogs
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
- Edited message
+ 2
+ .
-
-
+
+
+
+ Cats
+
+
+
+
+
+
@@ -69674,6 +69837,19 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
+
+
@@ -69714,7 +69890,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
},
]
}
- accessibilityLabel="johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername 10:00:00 AM Message translated into English."
+ accessibilityLabel="johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername 10:00:00 AM this is a normal message."
accessibilityRole="button"
accessibilityState={
{
@@ -69774,11 +69950,11 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-153"
>
@@ -70053,42 +70228,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"flexDirection": "row",
}
}
- >
-
-
-
-
-
-
+ />
- Translated message
+ this is a normal message
@@ -70207,7 +70348,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
},
]
}
- accessibilityLabel="johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername 10:00:00 AM Encrypted message."
+ accessibilityLabel="johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername 10:00:00 AM Edited message."
accessibilityRole="button"
accessibilityState={
{
@@ -70267,10 +70408,11 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-154"
>
@@ -70547,48 +70688,12 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
>
-
+
@@ -70627,7 +70732,8 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
>
- Encrypted message
+ Edited message
@@ -70698,450 +70804,12 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Error message
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -71513,36 +71182,40 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
}
>
-
+
-
-
+ {},
+ ]
+ }
+ >
+
+
+
- Message with read receipt
+ Translated message
@@ -71623,13 +71297,13 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
@@ -72072,36 +71746,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
-
-
-
-
-
-
-
- Message with read receipt
-
-
-
-
+ Encrypted message
+
@@ -72182,7 +71792,7 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
@@ -72474,112 +72083,6 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
}
}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Show all icons
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- small message
+ Error message
@@ -73289,280 +72230,18 @@ exports[`Story Snapshots: LongNameUser should match snapshot 1`] = `
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
-
-
@@ -73685,8 +72365,8 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -73786,7 +72466,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -73800,19 +72480,19 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -73906,6 +72586,25 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
>
johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+ 10:00 AM
+
+ >
+
+
+
+
-
- 10:00 AM
-
- this is a normal message
+ Message with read receipt
@@ -74013,13 +72724,13 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
@@ -74142,8 +72854,8 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -74243,7 +72955,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -74257,19 +72969,19 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -74363,6 +73075,25 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
>
johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+ 10:00 AM
+
-
+
+
+
+
-
- 10:00 AM
-
-
-
-
-
- Edited message
-
-
-
-
+ Encrypted message
+
@@ -74506,17 +73249,4151 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Encrypted message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Encrypted message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Encrypted message
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+ this is a normal message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+
+
+
+
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+ Edited message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+
+
+
+
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+ Translated message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+
+
+
+
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+ Encrypted message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
+
+
+
+
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+ Error message
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+ ],
+ {
+ "fontFamily": "custom",
+ "fontStyle": "normal",
+ "fontWeight": "normal",
+ },
+ {},
+ ]
+ }
+ testID="read-receipt-read"
+ >
+
+
@@ -74929,7 +77801,8 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
}
>
- Translated message
+ Message with read receipt
@@ -74999,13 +77872,13 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
+
+
+
@@ -75458,63 +78361,29 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Encrypted message
-
-
-
-
+ Encrypted message
+
@@ -75528,7 +78397,7 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
-
+
-
-
-
- 10:00 AM
-
-
-
-
-
- Error message
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
-
-
-
-
- johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
-
-
-
-
-
-
-
-
-
- 10:00 AM
-
-
-
-
-
-
-
- Message with read receipt
-
-
-
-
-
-
-
+ "fontFamily": "custom",
+ "fontStyle": "normal",
+ "fontWeight": "normal",
+ },
+ {},
+ ]
+ }
+ >
+
+
+
+
+
+
+
+
+ 10:00 AM
+
+
+
+
+ Encrypted message
+
-
+
-
-
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+ johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
+
+
-
-
-
-
-
-
-
-
-
-
+
+
-
-
+
+
+
-
-
- johndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusernamejohndoeverylongusername
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
- 10:00 AM
-
-
-
-
-
-
-
- Message with read receipt
-
-
-
-
-
-
-
+ "fontFamily": "custom",
+ "fontStyle": "normal",
+ "fontWeight": "normal",
+ },
+ {},
+ ]
+ }
+ >
+
+
+
+
+
-
+
+
+ 10:00 AM
+
+
+
+
+ Encrypted message
+
+
+
-
-
+
+
@@ -77556,819 +80098,37 @@ exports[`Story Snapshots: LongNameUserLargeFont should match snapshot 1`] = `
}
>
-
-
-
-
- Show all icons
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- small message
-
-
-
-
+ Encrypted message
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -78387,13 +80147,13 @@ exports[`Story Snapshots: Mentions should match snapshot 1`] = `
>
@@ -78774,6 +80534,7 @@ exports[`Story Snapshots: Mentions should match snapshot 1`] = `
}
>
@@ -79410,6 +81171,7 @@ exports[`Story Snapshots: Mentions should match snapshot 1`] = `
}
>
@@ -80041,6 +81803,7 @@ exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = `
}
>
@@ -80677,6 +82440,7 @@ exports[`Story Snapshots: MentionsLargeFont should match snapshot 1`] = `
}
>
@@ -81331,13 +83095,13 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
>
@@ -81718,6 +83482,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
}
>
@@ -82470,6 +84235,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
}
>
@@ -83276,6 +85042,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
}
>
@@ -84094,6 +85861,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
}
>
@@ -84803,6 +86571,7 @@ exports[`Story Snapshots: MessageWithNestedReplyAndFile should match snapshot 1`
}
>
@@ -85735,749 +87504,7 @@ exports[`Story Snapshots: MessageWithReadReceipt should match snapshot 1`] = `
}
>
-
-
-
-
- I'm fine!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- I'm fine!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- diego.mello
-
-
- 10:00 AM
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- I'm fine!
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-`;
-
-exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot 1`] = `
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ I'm fine!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -86945,8 +87962,8 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -87046,7 +88063,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -87060,19 +88077,19 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -87166,6 +88183,25 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
>
diego.mello
+
+ 10:00 AM
+
-
+
-
- 10:00 AM
-
@@ -87451,6 +88469,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
}
>
-
+
@@ -87558,6 +88577,19 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
+
+
+`;
+
+exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot 1`] = `
+
+
@@ -87598,7 +88630,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM I'm fine!. Message was read"
+ accessibilityLabel="diego.mello 10:00:00 AM I'm fine!. Message was not read"
accessibilityRole="button"
accessibilityState={
{
@@ -87658,10 +88690,11 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-193"
>
-
+
@@ -87963,7 +88996,6 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
{
"color": "#6C727A",
},
- undefined,
]
}
>
@@ -87977,6 +89009,7 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
}
>
-
+
@@ -88301,19 +89336,6 @@ exports[`Story Snapshots: MessageWithReadReceiptLargeFont should match snapshot
-
-
-`;
-
-exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
-
-
@@ -88354,7 +89376,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
},
]
}
- accessibilityLabel="diego.mello 10:00:00 AM I'm fine!."
+ accessibilityLabel="diego.mello 10:00:00 AM I'm fine!. Message was read"
accessibilityRole="button"
accessibilityState={
{
@@ -88414,10 +89436,11 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-196"
>
@@ -88444,8 +89467,8 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
[
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
},
{
"marginTop": 4,
@@ -88545,7 +89568,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
"top": "50%",
}
}
- height={36}
+ height={46.800000000000004}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -88559,19 +89582,19 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
},
]
}
style={
{
"borderRadius": 4,
- "height": 36,
- "width": 36,
+ "height": 46.800000000000004,
+ "width": 46.800000000000004,
}
}
transition={null}
- width={36}
+ width={46.800000000000004}
/>
@@ -88665,35 +89688,65 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
>
diego.mello
+
+
- 10:00 AM
+
-
+
+ 10:00 AM
+
-
-
-
-
-
- I'm a very long long title and I'll break
-
-
-
-
-
-
- How are you?
-
-
-
-
-
-
-
+ "color": "#2F343D",
+ },
+ ]
+ }
+ >
+ I'm fine!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -88943,14 +90082,27 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
+
+
@@ -89388,215 +90540,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
},
]
}
- testID="reply-rocket.cat-How are you? :nyan_rocket:"
- >
-
-
-
-
- rocket.cat
-
-
-
-
-
-
- How are you?
-
-
-
-
-
-
-
-
-
-
-
-
-
- rocket.cat
+ I'm a very long long title and I'll break
- How are you?
+ How are you?
-
-
-
-
-
-
-
- Reply attachment can have a description
-
-
-
-
@@ -90267,71 +91113,220 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
}
>
-
-
-
- Looks cool!
-
-
-
+
+
+ rocket.cat
+
+
+
+
+
+
+ How are you?
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
- What you think about this one?
-
-
+ How are you?
-
-
-
-
+
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+ Reply attachment can have a description
+
+
+
+
+
+
+
+
+
+
+
+ I'm fine!
+
+
+
@@ -90686,13 +91663,13 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
@@ -91073,7 +92050,8 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
}
>
- Yes, I am
+ Looks cool!
@@ -91293,68 +92271,7 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
}
>
- Are you seeing this mario
-
-
-
-
-
- ?
+ What you think about this one?
@@ -91552,27 +92469,14 @@ exports[`Story Snapshots: MessageWithReply should match snapshot 1`] = `
-
-
-`;
-
-exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
-
-
@@ -91952,6 +92856,66 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
}
}
>
+
+
+
+
+
+ Yes, I am
+
+
+
+
+
rocket.cat
-
- script.sh
-
-
- script.sh
-
+
+
+
+
+
+ Are you seeing this mario
+
+
+
+
+
+ ?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
- Here is the file
-
-
-
-
-
@@ -92181,14 +93337,27 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
+
+
+`;
+
+exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
+
+
@@ -92626,7 +93795,7 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
},
]
}
- testID="reply-rocket.cat-This is a configuration file with **important** settings"
+ testID="reply-rocket.cat-undefined"
>
- config.yaml
+ script.sh
-
-
-
-
- This is a configuration file with
-
-
-
- important
-
-
-
- settings
-
-
-
-
+ script.sh
+
- Got it!
+ Here is the file
@@ -92875,13 +93968,13 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
@@ -93319,7 +94412,7 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
},
]
}
- testID="reply-rocket.cat-undefined"
+ testID="reply-rocket.cat-This is a configuration file with **important** settings"
>
- index.ts
+ config.yaml
-
- index.ts
-
-
-
-
-
-
-
-
-
- rocket.cat
-
-
- styles.css
+ }
+ }
+ >
+
+ This is a configuration file with
+
+
+
+ important
+
+
+
+ settings
+
+
-
- styles.css
-
- Multiple files
+ Got it!
@@ -93641,27 +94661,14 @@ exports[`Story Snapshots: MessageWithReplyAndFile should match snapshot 1`] = `
-
-
-`;
-
-exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot 1`] = `
-
-
@@ -93784,8 +94792,8 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
[
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
},
{
"marginTop": 4,
@@ -93885,7 +94893,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
"top": "50%",
}
}
- height={46.800000000000004}
+ height={36}
nativeViewRef={{"current":"NativeComponent"}}
onError={[Function]}
onLoad={[Function]}
@@ -93899,19 +94907,19 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
},
]
}
style={
{
"borderRadius": 4,
- "height": 46.800000000000004,
- "width": 46.800000000000004,
+ "height": 36,
+ "width": 36,
}
}
transition={null}
- width={46.800000000000004}
+ width={36}
/>
@@ -94005,6 +95013,25 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
>
diego.mello
+
+ 10:00 AM
+
-
- 10:00 AM
-
- script.sh
+ index.ts
- script.sh
+ index.ts
+
+
+
+
+
+
+
+
+
+
+
+ rocket.cat
+
+
+ styles.css
+
+
+
+ styles.css
@@ -94201,7 +95359,8 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
- Here is the file
+ Multiple files
@@ -94270,14 +95429,27 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
+
+
+`;
+
+exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot 1`] = `
+
+
+ 10:00 AM
+
+
+
+
+
+
+
+
+
+ rocket.cat
+
+
+ script.sh
+
+
+
+ script.sh
+
+
+
+
+
+
+
+
+
+
+
+ Here is the file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ diego.mello
+
+
+
+
+
10:00 AM
@@ -94894,6 +96683,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
@@ -95661,6 +97451,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
@@ -96118,6 +97909,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
}
>
@@ -96936,6 +98728,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
}
>
@@ -97645,6 +99438,7 @@ exports[`Story Snapshots: MessageWithReplyAndFileLargeFont should match snapshot
}
>
@@ -98718,6 +100512,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] =
@@ -99391,6 +101186,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] =
@@ -99848,6 +101644,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] =
}
>
@@ -100654,6 +102451,7 @@ exports[`Story Snapshots: MessageWithReplyLargeFont should match snapshot 1`] =
}
>
@@ -101535,6 +103333,7 @@ exports[`Story Snapshots: MessageWithThread should match snapshot 1`] = `
}
>
@@ -106270,6 +108087,7 @@ exports[`Story Snapshots: MessageWithThreadLargeFont should match snapshot 1`] =
}
>
@@ -111004,6 +112840,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
}
>
@@ -111391,7 +113228,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Small name with icons-pinned"
>
@@ -111472,7 +113309,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Small name with icons-edited"
>
-
-
-
-
- Small name with icons
-
-
-
-
+ Encrypted message
+
@@ -111712,13 +113519,13 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
@@ -112099,6 +113906,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
}
>
@@ -112486,7 +114294,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Medium name with icons-pinned"
>
@@ -112567,7 +114375,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Medium name with icons-edited"
>
-
-
-
-
- Medium name with icons
-
-
-
-
+ Encrypted message
+
@@ -112807,13 +114585,13 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
@@ -113194,6 +114972,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
}
>
@@ -113581,7 +115360,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Long name with icons - timestamp visible-pinned"
>
@@ -113662,7 +115441,7 @@ exports[`Story Snapshots: Names should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Long name with icons - timestamp visible-edited"
>
-
-
-
-
- Long name with icons - timestamp visible
-
-
-
-
+ Encrypted message
+
@@ -113915,13 +115664,13 @@ exports[`Story Snapshots: Pinned should match snapshot 1`] = `
>
@@ -114318,7 +116067,7 @@ exports[`Story Snapshots: Pinned should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Message header-pinned"
>
@@ -114332,6 +116081,7 @@ exports[`Story Snapshots: Pinned should match snapshot 1`] = `
}
>
@@ -115072,7 +116825,6 @@ exports[`Story Snapshots: PinnedLargeFont should match snapshot 1`] = `
{
"color": "#6C727A",
},
- undefined,
]
}
>
@@ -115086,6 +116838,7 @@ exports[`Story Snapshots: PinnedLargeFont should match snapshot 1`] = `
}
>
@@ -115810,6 +117565,7 @@ exports[`Story Snapshots: Reactions should match snapshot 1`] = `
}
>
@@ -116811,6 +118567,7 @@ exports[`Story Snapshots: Reactions should match snapshot 1`] = `
}
>
@@ -118281,6 +120038,7 @@ exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = `
}
>
@@ -119282,6 +121040,7 @@ exports[`Story Snapshots: ReactionsLargeFont should match snapshot 1`] = `
}
>
@@ -120753,6 +122512,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButton should m
}
>
@@ -122527,6 +124293,7 @@ exports[`Story Snapshots: SequentialThreadMessagesFollowingThreadButtonLargeFont
}
>
@@ -127248,13 +129037,13 @@ exports[`Story Snapshots: ShowButtonAsAttachment should match snapshot 1`] = `
@@ -127957,13 +129746,13 @@ exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot
>
@@ -128488,13 +130277,13 @@ exports[`Story Snapshots: ShowButtonAsAttachmentLargeFont should match snapshot
@@ -129197,13 +130986,13 @@ exports[`Story Snapshots: StaticAvatar should match snapshot 1`] = `
>
@@ -129584,6 +131373,7 @@ exports[`Story Snapshots: StaticAvatar should match snapshot 1`] = `
}
>
@@ -130054,6 +131844,7 @@ exports[`Story Snapshots: StaticAvatarLargeFont should match snapshot 1`] = `
}
>
@@ -143228,13 +145071,13 @@ exports[`Story Snapshots: TempLargeFont should match snapshot 1`] = `
>
@@ -143695,13 +145538,13 @@ exports[`Story Snapshots: ThumbnailFromServer should match snapshot 1`] = `
>
@@ -144293,6 +146136,7 @@ exports[`Story Snapshots: ThumbnailFromServer should match snapshot 1`] = `
@@ -144974,6 +146818,7 @@ exports[`Story Snapshots: ThumbnailFromServerLargeFont should match snapshot 1`]
@@ -145444,6 +147289,7 @@ exports[`Story Snapshots: TimeFormat should match snapshot 1`] = `
}
>
@@ -145914,6 +147760,7 @@ exports[`Story Snapshots: TimeFormatLargeFont should match snapshot 1`] = `
}
>
@@ -146242,7 +148090,7 @@ exports[`Story Snapshots: Translated should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=72",
+ "uri": "https://open.rocket.chat/avatar/other.user?format=png&size=72",
},
]
}
@@ -146325,7 +148173,7 @@ exports[`Story Snapshots: Translated should match snapshot 1`] = `
"gap": 4,
}
}
- testID="username-header-diego.mello"
+ testID="username-header-other.user"
>
- diego.mello
+ other.user
@@ -146420,6 +148267,7 @@ exports[`Story Snapshots: Translated should match snapshot 1`] = `
}
>
@@ -146748,7 +148597,7 @@ exports[`Story Snapshots: TranslatedLargeFont should match snapshot 1`] = `
"headers": {
"User-Agent": "RC Mobile; ios unknown; vunknown (unknown)",
},
- "uri": "https://open.rocket.chat/avatar/diego.mello?format=png&size=93.60000000000001",
+ "uri": "https://open.rocket.chat/avatar/other.user?format=png&size=93.60000000000001",
},
]
}
@@ -146831,7 +148680,7 @@ exports[`Story Snapshots: TranslatedLargeFont should match snapshot 1`] = `
"gap": 4,
}
}
- testID="username-header-diego.mello"
+ testID="username-header-other.user"
>
- diego.mello
+ other.user
@@ -146926,6 +148774,7 @@ exports[`Story Snapshots: TranslatedLargeFont should match snapshot 1`] = `
}
>
@@ -148112,6 +149961,7 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdown should match snapshot
@@ -149298,6 +151148,7 @@ exports[`Story Snapshots: TwoShortCustomFieldsWithMarkdownLargeFont should match
@@ -149975,13 +151826,13 @@ exports[`Story Snapshots: URL should match snapshot 1`] = `
@@ -150362,6 +152213,7 @@ exports[`Story Snapshots: URL should match snapshot 1`] = `
}
>
@@ -151346,13 +153199,13 @@ exports[`Story Snapshots: URLImagePreview should match snapshot 1`] = `
@@ -151798,13 +153651,13 @@ exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = `
>
@@ -152294,13 +154147,13 @@ exports[`Story Snapshots: URLImagePreviewLargeFont should match snapshot 1`] = `
@@ -152746,13 +154599,13 @@ exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = `
>
@@ -153340,13 +155193,13 @@ exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = `
@@ -153727,6 +155580,7 @@ exports[`Story Snapshots: URLLargeFont should match snapshot 1`] = `
}
>
@@ -154602,6 +156457,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
}
>
@@ -154989,7 +156845,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Small username with icons-pinned"
>
@@ -155070,7 +156926,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Small username with icons-edited"
>
-
-
-
-
- Small username with icons
-
-
-
-
+ Encrypted message
+
@@ -155310,13 +157136,13 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
@@ -155697,6 +157523,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
}
>
@@ -156084,7 +157911,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Medium username with icons-pinned"
>
@@ -156165,7 +157992,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Medium username with icons-edited"
>
-
-
-
-
- Medium username with icons
-
-
-
-
+ Encrypted message
+
@@ -156405,13 +158202,13 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
@@ -156792,6 +158589,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
}
>
@@ -157179,7 +158977,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
{},
]
}
- testID="undefined-pinned"
+ testID="Long username with icons - timestamp visible-pinned"
>
@@ -157260,7 +159058,7 @@ exports[`Story Snapshots: Usernames should match snapshot 1`] = `
"paddingLeft": 5,
}
}
- testID="undefined-edited"
+ testID="Long username with icons - timestamp visible-edited"
>
-
-
-
-
- Long username with icons - timestamp visible
-
-
-
-
+ Encrypted message
+
@@ -157513,13 +159281,13 @@ exports[`Story Snapshots: WithAlias should match snapshot 1`] = `
>
@@ -157919,6 +159687,7 @@ exports[`Story Snapshots: WithAlias should match snapshot 1`] = `
}
>
@@ -158395,6 +160164,7 @@ exports[`Story Snapshots: WithAlias should match snapshot 1`] = `
}
>
@@ -158884,6 +160654,7 @@ exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = `
}
>
@@ -159360,6 +161131,7 @@ exports[`Story Snapshots: WithAliasLargeFont should match snapshot 1`] = `
}
>
@@ -160184,13 +161956,13 @@ exports[`Story Snapshots: WithAudio should match snapshot 1`] = `
@@ -163086,13 +164862,13 @@ exports[`Story Snapshots: WithAudioLargeFont should match snapshot 1`] = `
@@ -165883,13 +167665,13 @@ exports[`Story Snapshots: WithFile should match snapshot 1`] = `
@@ -167201,13 +168984,13 @@ exports[`Story Snapshots: WithFileLargeFont should match snapshot 1`] = `
@@ -168303,13 +170087,13 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = `
@@ -168690,6 +170474,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = `
}
>
@@ -169852,6 +171637,7 @@ exports[`Story Snapshots: WithImage should match snapshot 1`] = `
}
>
@@ -170926,13 +172712,13 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = `
>
@@ -171556,13 +173342,13 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = `
@@ -172340,6 +174127,7 @@ exports[`Story Snapshots: WithImageLargeFont should match snapshot 1`] = `
}
>
@@ -173414,13 +175202,13 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = `
>
@@ -174046,13 +175834,13 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = `
@@ -174788,6 +176577,7 @@ exports[`Story Snapshots: WithVideo should match snapshot 1`] = `
}
>
@@ -175765,13 +177555,13 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = `
@@ -176298,13 +178088,13 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = `
@@ -176685,6 +178475,7 @@ exports[`Story Snapshots: WithVideoLargeFont should match snapshot 1`] = `
}
>
= {
baseUrl: 'https://open.rocket.chat',
user: { id: 'user-id', username: 'rocket.cat', token: 'token' },
- onLongPress: () => {},
- translateLanguage: undefined
+ showAttachment: undefined
};
+const mockItem = { id: 'msg-id', msg: '', u: { username: 'rocket.cat' }, autoTranslate: false } as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({ store, children }: { store: ReturnType; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
const MOCK_IMAGE_1 = {
image_url: 'https://picsum.photos/seed/1/400/300',
image_type: 'image/jpeg',
@@ -51,55 +62,45 @@ export default {
// Single image on server < 8.4: renders with Markdown caption if description is set
export const SingleImageOldServer = () => (
-
-
-
-
-
-
-
+
+
+
+
+
);
// Single image on server >= 8.4: renders with AltTextLabel pill below image
export const SingleImageNewServer = () => (
-
-
-
-
-
-
-
+
+
+
+
+
);
// Single image without alt text on new server: no label
export const SingleImageNoAlt = () => (
-
-
-
-
-
-
-
+
+
+
+
+
);
// Multiple images on server >= 8.4: each attachment renders as its own image row
export const MultipleImagesNewServer = () => (
-
-
-
-
-
-
-
+
+
+
+
+
);
// 4 images on server < 8.4: renders stacked (existing behavior)
export const MultipleImagesOldServer = () => (
-
-
-
-
-
-
-
+
+
+
+
+
);
diff --git a/app/containers/message/components/stories/Broadcast.stories.tsx b/app/containers/message/components/stories/Broadcast.stories.tsx
new file mode 100644
index 00000000000..c0c53a45251
--- /dev/null
+++ b/app/containers/message/components/stories/Broadcast.stories.tsx
@@ -0,0 +1,56 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import BroadcastLeaf from '../Broadcast';
+
+const store = createMockedStore();
+
+const otherAuthorItem = {
+ id: 'msg-id',
+ msg: 'Broadcast message',
+ u: { _id: 'author-id', username: 'other.user' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const ownAuthorItem = {
+ id: 'msg-id',
+ msg: 'Broadcast message',
+ u: { _id: 'reader-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ broadcast: true,
+ user: { id: 'reader-id', username: 'rocket.cat' },
+ replyBroadcast: () => {}
+};
+
+const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Broadcast'
+};
+
+// A broadcast message from another user renders the reply button.
+export const Broadcast = () => (
+
+
+
+);
+
+// A broadcast message authored by the reader renders nothing.
+export const BroadcastOwn = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/CallButton.stories.tsx b/app/containers/message/components/stories/CallButton.stories.tsx
new file mode 100644
index 00000000000..0488be2988d
--- /dev/null
+++ b/app/containers/message/components/stories/CallButton.stories.tsx
@@ -0,0 +1,40 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import CallButtonLeaf from '../CallButton';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: '',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ handleEnterCall: () => {}
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/CallButton'
+};
+
+// The join-call button always renders its label and video icon.
+export const CallButton = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/CollapsibleQuote.stories.tsx b/app/containers/message/components/stories/CollapsibleQuote.stories.tsx
new file mode 100644
index 00000000000..e1a5c57b0bd
--- /dev/null
+++ b/app/containers/message/components/stories/CollapsibleQuote.stories.tsx
@@ -0,0 +1,52 @@
+import { type ReactNode } from 'react';
+import { View } from 'react-native';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import CollapsibleQuote from '../Attachments/CollapsibleQuote';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: '',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+const testAttachment = {
+ ts: '1970-01-01T00:00:00.000Z',
+ title: 'Engineering (9 today)',
+ fields: [
+ {
+ title: 'Out Today:\n',
+ value:
+ 'Ricardo Mellu, 1 day, until Fri Mar 11\nLoma, 1 day, until Fri Mar 11\nAnitta, 3 hours\nDiego Carlitos, 19 days, until Fri Mar 11\nGabriel Vasconcelos, 5 days, until Fri Mar 11\nJorge Leite, 1 day, until Fri Mar 11\nKevin Aleman, 1 day, until Fri Mar 11\nPierre, 1 day, until Fri Mar 11\nTiago Evangelista Pinto, 1 day, until Fri Mar 11'
+ }
+ ],
+ attachments: [],
+ collapsed: true
+};
+
+export default {
+ title: 'CollapsibleQuote'
+};
+
+export const Item = () => (
+
+
+
+
+
+);
diff --git a/app/containers/message/components/stories/Content.stories.tsx b/app/containers/message/components/stories/Content.stories.tsx
new file mode 100644
index 00000000000..87df5e33651
--- /dev/null
+++ b/app/containers/message/components/stories/Content.stories.tsx
@@ -0,0 +1,41 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import ContentLeaf from '../Content';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'This is a plain text message',
+ t: undefined,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader' }
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Content'
+};
+
+// A regular text message renders its Markdown body.
+export const Content = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/Discussion.stories.tsx b/app/containers/message/components/stories/Discussion.stories.tsx
new file mode 100644
index 00000000000..8a96a8463f1
--- /dev/null
+++ b/app/containers/message/components/stories/Discussion.stories.tsx
@@ -0,0 +1,43 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import DiscussionLeaf from '../Discussion';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'Discussion topic message',
+ dcount: 5,
+ dlm: new Date('2024-01-01T10:20:00.000Z'),
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader' },
+ onDiscussionPress: () => {}
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Discussion'
+};
+
+// A discussion message renders the started-discussion label, topic, reply count and last-message time.
+export const Discussion = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/Emoji.stories.tsx b/app/containers/message/components/stories/Emoji.stories.tsx
new file mode 100644
index 00000000000..54e01483a9e
--- /dev/null
+++ b/app/containers/message/components/stories/Emoji.stories.tsx
@@ -0,0 +1,34 @@
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { selectServerRequest } from '../../../../actions/server';
+import { setCustomEmojis } from '../../../../actions/customEmojis';
+import EmojiLeaf from '../Emoji';
+
+const store = createMockedStore();
+store.dispatch(selectServerRequest('https://open.rocket.chat', '7.0.0'));
+
+const storeWithCustomEmoji = createMockedStore();
+storeWithCustomEmoji.dispatch(selectServerRequest('https://open.rocket.chat', '7.0.0'));
+storeWithCustomEmoji.dispatch(setCustomEmojis({ react_rocket: { name: 'react_rocket', extension: 'png' } }));
+
+const standardEmojiStyle = { fontSize: 30 };
+const customEmojiStyle = { width: 30, height: 30 };
+
+export default {
+ title: 'Message/Emoji'
+};
+
+// A shortname with no custom-emoji match renders as unicode text.
+export const Emoji = () => (
+
+
+
+);
+
+// A shortname that matches a custom emoji renders the custom image.
+export const EmojiCustom = () => (
+
+
+
+);
diff --git a/app/containers/message/Components/Attachments/Image/Image.stories.tsx b/app/containers/message/components/stories/Image.stories.tsx
similarity index 92%
rename from app/containers/message/Components/Attachments/Image/Image.stories.tsx
rename to app/containers/message/components/stories/Image.stories.tsx
index 78018ab8500..e762e7a75f2 100644
--- a/app/containers/message/Components/Attachments/Image/Image.stories.tsx
+++ b/app/containers/message/components/stories/Image.stories.tsx
@@ -1,7 +1,7 @@
import { View } from 'react-native';
-import { WidthAwareContext } from '../../WidthAwareView';
-import { MessageImage } from './Image';
+import { WidthAwareContext } from '../WidthAwareView';
+import { MessageImage } from '../Attachments/Image/Image';
const MOCK_URI = 'https://picsum.photos/400/300';
diff --git a/app/containers/message/Message.stories.tsx b/app/containers/message/components/stories/Message.stories.tsx
similarity index 90%
rename from app/containers/message/Message.stories.tsx
rename to app/containers/message/components/stories/Message.stories.tsx
index bca767224db..d8b2c37816e 100644
--- a/app/containers/message/Message.stories.tsx
+++ b/app/containers/message/components/stories/Message.stories.tsx
@@ -1,20 +1,24 @@
+import { type ComponentType } from 'react';
import { ScrollView } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
-import MessageComponent from './Message';
-import { E2E_MESSAGE_TYPE } from '../../lib/constants/keys';
-import { messagesStatus } from '../../lib/constants/messagesStatus';
-import { themes } from '../../lib/constants/colors';
-import MessageSeparator from '../MessageSeparator';
+import MessageContainer from '../../index';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { E2E_MESSAGE_TYPE } from '../../../../lib/constants/keys';
+import { messagesStatus } from '../../../../lib/constants/messagesStatus';
+import { themes } from '../../../../lib/constants/colors';
+import MessageSeparator from '../../../MessageSeparator';
import {
BASE_ROW_HEIGHT,
BASE_ROW_HEIGHT_CONDENSED,
FONT_SCALE_LIMIT,
ResponsiveLayoutContext
-} from '../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
-import { mockedStore as store } from '../../reducers/mockedStore';
-import { updateSettings } from '../../actions/settings';
-import { setCustomEmojis } from '../../actions/customEmojis';
+} from '../../../../lib/hooks/useResponsiveLayout/useResponsiveLayout';
+import { mockedStore as store } from '../../../../reducers/mockedStore';
+import { updateSettings } from '../../../../actions/settings';
+import { setCustomEmojis } from '../../../../actions/customEmojis';
+import { createMessageActionStore, MessageActionStoreContext } from '../../stores/MessageActionStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
const _theme = 'light';
@@ -70,10 +74,35 @@ const responsiveLayoutProviderLargeFontValue = (fontScale: number) => ({
height: 800
});
+const containerHandlers = {
+ onPress: () => {},
+ onLongPress: () => {},
+ threadBadgeColor: themes[_theme].badgeBackgroundLevel1
+};
+
+const roomHandlers: Partial = {
+ user,
+ baseUrl,
+ reactionInit: () => {},
+ replyBroadcast: () => {},
+ onReactionPress: () => {},
+ onEncryptedPress: () => {},
+ onDiscussionPress: () => {},
+ onThreadPress: () => {},
+ onReactionLongPress: () => {},
+ onAnswerButtonPress: () => {},
+ jumpToMessage: () => {},
+ navToRoomInfo: () => {},
+ showAttachment: undefined,
+ blockAction: undefined,
+ handleEnterCall: undefined,
+ fetchThreadName: undefined
+};
+
export default {
title: 'Message',
decorators: [
- (Story: any) => (
+ (Story: ComponentType) => (
@@ -83,36 +112,167 @@ export default {
]
};
-export const Message = (props: any) => (
-
-
-
-);
+const rid = 'GENERAL';
+
+const otherUser = { _id: 'other-user', username: 'other.user', name: 'Other User' };
+
+const editedByFixture = { _id: 'edited-by', username: 'diego.mello' };
+
+let storyMessageIdCounter = 0;
+
+const buildMessage = (overrides: Record = {}): TAnyMessageModel => {
+ const definedOverrides = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
+ return {
+ id: `story-message-${++storyMessageIdCounter}`,
+ msg: undefined,
+ t: undefined,
+ ts: date,
+ u: author,
+ alias: undefined,
+ groupable: true,
+ avatar: undefined,
+ emoji: undefined,
+ attachments: undefined,
+ urls: undefined,
+ status: undefined,
+ pinned: undefined,
+ editedBy: undefined,
+ reactions: undefined,
+ role: undefined,
+ drid: undefined,
+ dcount: undefined,
+ dlm: undefined,
+ tmid: undefined,
+ tcount: undefined,
+ tlm: undefined,
+ replies: undefined,
+ mentions: undefined,
+ channels: undefined,
+ unread: undefined,
+ autoTranslate: undefined,
+ translations: undefined,
+ tmsg: undefined,
+ blocks: undefined,
+ e2e: undefined,
+ md: undefined,
+ comment: undefined,
+ ...definedOverrides
+ } as unknown as TAnyMessageModel;
+};
+
+// Builds the message model for a single / call, pulling the
+// scalar display flags (author/type/hasError/status/isEdited/isTranslated) out of the
+// props that used to be passed straight to the leaf component.
+const buildItem = (props: Record): TAnyMessageModel => {
+ const { author: authorOverride, type, hasError, status, isEdited, isTranslated, ...itemProps } = props;
+ const resolvedAuthor = authorOverride ?? (isTranslated ? otherUser : author);
+ return buildMessage({
+ ...itemProps,
+ u: resolvedAuthor,
+ t: type,
+ status: status ?? (hasError ? messagesStatus.ERROR : undefined),
+ editedBy: isEdited ? editedByFixture : undefined,
+ msg: isTranslated ? 'Untranslated message' : itemProps.msg,
+ autoTranslate: isTranslated ? true : undefined,
+ translations: isTranslated ? [{ _id: 'translation-1', language: 'en', value: itemProps.msg ?? 'Message' }] : undefined
+ });
+};
+
+// Synthesizes a sibling message so MessageContainer's real grouping logic (computeIsHeader)
+// derives isHeader=false the same way the old isHeader={false} prop used to force it.
+const buildGroupedPreviousItem = (item: TAnyMessageModel): TAnyMessageModel =>
+ buildMessage({
+ id: `${item.id}-prev`,
+ u: item.u,
+ ts: item.ts,
+ tmid: item.tmid,
+ t: item.t,
+ groupable: item.groupable,
+ status: item.status
+ });
+
+// Synthesizes a non-matching sibling thread message so isThreadReply derives true,
+// the same way the old isThreadReply prop used to force it.
+const buildThreadReplyPreviousItem = (item: TAnyMessageModel): TAnyMessageModel =>
+ buildMessage({ id: `${item.id}-prev`, u: otherUser, ts: item.ts });
+
+const resolvePreviousItem = (item: TAnyMessageModel, isHeader: boolean | undefined, isThreadReply: boolean | undefined) => {
+ if (isHeader === false) {
+ return buildGroupedPreviousItem(item);
+ }
+ if (isThreadReply) {
+ return buildThreadReplyPreviousItem(item);
+ }
+ return undefined;
+};
+
+const renderMessageStory = (fontScale: number, props: Record) => {
+ const {
+ isHeader,
+ isThreadReply,
+ previousItem: previousItemOverride,
+ broadcast,
+ archived,
+ timeFormat = 'LT',
+ useRealName,
+ isReadReceiptEnabled,
+ isIgnored,
+ isTranslated,
+ autoTranslateRoom,
+ autoTranslateLanguage,
+ ...itemProps
+ } = props as {
+ isHeader?: boolean;
+ isThreadReply?: boolean;
+ previousItem?: TAnyMessageModel;
+ broadcast?: boolean;
+ archived?: boolean;
+ timeFormat?: string;
+ useRealName?: boolean;
+ isReadReceiptEnabled?: boolean;
+ isIgnored?: boolean;
+ isTranslated?: boolean;
+ autoTranslateRoom?: boolean;
+ autoTranslateLanguage?: string;
+ [key: string]: unknown;
+ };
+
+ const item = buildItem({ ...itemProps, isTranslated });
+ const previousItem = previousItemOverride ?? resolvePreviousItem(item, isHeader, isThreadReply);
+ store.dispatch(updateSettings('UI_Use_Real_Name', !!useRealName));
+
+ const containerProps = {
+ item,
+ previousItem,
+ isIgnored,
+ ...containerHandlers
+ };
+
+ const room: Partial = {
+ rid,
+ Message_GroupingPeriod: 300,
+ timeFormat,
+ broadcast,
+ archived,
+ isReadReceiptEnabled,
+ autoTranslateRoom: autoTranslateRoom ?? (isTranslated ? true : undefined),
+ autoTranslateLanguage: autoTranslateLanguage ?? (isTranslated ? 'en' : undefined),
+ ...roomHandlers
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export const Message = (props: Record) => renderMessageStory(1, props);
// The large font components are not perfect because the text's font scale increases only with the device's font size setting.
-const MessageLargeFont = (props: any) => (
-
-
-
-);
+const MessageLargeFont = (props: Record) => renderMessageStory(FONT_SCALE_LIMIT, props);
export const Basic = () => (
<>
@@ -278,19 +438,12 @@ export const Encrypted = () => (
usernames: [user.username]
}
]}
- onReactionPress={() => {}}
type='e2e'
/>
- alert('Error pressed')}
- type='e2e'
- />
+
>
@@ -316,19 +469,12 @@ export const EncryptedLargeFont = () => (
usernames: [user.username]
}
]}
- onReactionPress={() => {}}
type='e2e'
/>
- alert('Error pressed')}
- type='e2e'
- />
+
>
@@ -586,7 +732,6 @@ export const Reactions = () => (
usernames: new Array(9999)
}
]}
- onReactionPress={() => {}}
/>
(
usernames: [user.username]
}
]}
- onReactionPress={() => {}}
/>
>
);
@@ -651,7 +795,6 @@ export const ReactionsLargeFont = () => (
usernames: new Array(9999)
}
]}
- onReactionPress={() => {}}
/>
(
usernames: [user.username]
}
]}
- onReactionPress={() => {}}
/>
>
);
@@ -2248,11 +2390,9 @@ export const ColoredAttachmentsLargeFont = () => (
/>
);
-export const Broadcast = () => alert('broadcast!')} />;
+export const Broadcast = () => ;
-export const BroadcastLargeFont = () => (
- alert('broadcast!')} />
-);
+export const BroadcastLargeFont = () => ;
export const Archived = () => ;
@@ -2260,32 +2400,15 @@ export const ArchivedLargeFont = () => alert('Error pressed')} />
- alert('Error pressed')}
- isHeader={false}
- />
+
+
>
);
export const ErrorLargeFont = () => (
<>
- alert('Error pressed')}
- />
- alert('Error pressed')}
- isHeader={false}
- />
+
+
>
);
@@ -2293,9 +2416,19 @@ export const Temp = () => ;
-export const Editing = () => ;
+const editingStore = createMessageActionStore({ kind: 'edit', messageId: 'editing-message' });
-export const EditingLargeFont = () => ;
+export const Editing = () => (
+
+
+
+);
+
+export const EditingLargeFont = () => (
+
+
+
+);
export const SystemMessages = () => (
<>
@@ -2725,3 +2858,7 @@ export const InlineKatex = () => (
>
);
export const InlineKatexLargeFont = () => ;
+
+export const JitsiCall = () => ;
+
+export const JitsiCallLargeFont = () => ;
diff --git a/app/containers/message/components/stories/MessageAvatar.stories.tsx b/app/containers/message/components/stories/MessageAvatar.stories.tsx
new file mode 100644
index 00000000000..59ab1850732
--- /dev/null
+++ b/app/containers/message/components/stories/MessageAvatar.stories.tsx
@@ -0,0 +1,42 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { selectServerRequest } from '../../../../actions/server';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import MessageAvatarLeaf from '../MessageAvatar';
+
+const store = createMockedStore();
+store.dispatch(selectServerRequest('https://open.rocket.chat', '7.0.0'));
+
+const item = {
+ id: 'msg-id',
+ msg: 'Message',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader' }
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/MessageAvatar'
+};
+
+// Header messages render the author avatar from the username.
+export const MessageAvatar = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/Reactions.stories.tsx b/app/containers/message/components/stories/Reactions.stories.tsx
new file mode 100644
index 00000000000..77af06be29c
--- /dev/null
+++ b/app/containers/message/components/stories/Reactions.stories.tsx
@@ -0,0 +1,47 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import ReactionsLeaf from '../Reactions';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'Message with reactions',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false,
+ reactions: [
+ { emoji: ':joy:', usernames: ['rocket.cat', 'diego.mello'] },
+ { emoji: ':heart:', usernames: ['diego.mello'] }
+ ]
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'rocket.cat' },
+ onReactionPress: () => {},
+ onReactionLongPress: () => {},
+ reactionInit: () => {}
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Reactions'
+};
+
+// A message with reactions renders each reaction pill plus the add-reaction button.
+export const Reactions = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RepliedThread.stories.tsx b/app/containers/message/components/stories/RepliedThread.stories.tsx
new file mode 100644
index 00000000000..8a199969d0f
--- /dev/null
+++ b/app/containers/message/components/stories/RepliedThread.stories.tsx
@@ -0,0 +1,38 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import RepliedThreadLeaf from '../RepliedThread';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'Thread reply body',
+ tmid: 'thread-1',
+ tmsg: 'Original thread message',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RepliedThread'
+};
+
+// A header message that replies to a thread renders the replied-thread preview.
+export const RepliedThread = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/Edited.stories.tsx b/app/containers/message/components/stories/RightIcons/Edited.stories.tsx
new file mode 100644
index 00000000000..2cf180fc3fc
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/Edited.stories.tsx
@@ -0,0 +1,51 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { MessageRoomProvider } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import EditedLeaf from '../../RightIcons/Edited';
+
+const store = createMockedStore();
+
+const editedItem = {
+ id: 'msg-id',
+ msg: 'Edited message',
+ editedBy: { username: 'rocket.cat' },
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const plainItem = {
+ id: 'msg-id',
+ msg: 'Untouched message',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/Edited'
+};
+
+// An edited message renders the edit icon.
+export const Edited = () => (
+
+
+
+);
+
+// A message that was never edited renders nothing.
+export const EditedHidden = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx b/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx
new file mode 100644
index 00000000000..6a7005128bb
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/Encrypted.stories.tsx
@@ -0,0 +1,57 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { E2E_MESSAGE_TYPE } from '../../../../../lib/constants/keys';
+import { MessageRoomProvider, type MessageRoomState } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import EncryptedLeaf from '../../RightIcons/Encrypted';
+
+const store = createMockedStore();
+
+const encryptedItem = {
+ id: 'msg-id',
+ msg: 'Encrypted message',
+ t: E2E_MESSAGE_TYPE,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const plainItem = {
+ id: 'msg-id',
+ msg: 'Plain message',
+ t: undefined,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ onEncryptedPress: () => {}
+};
+
+const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/Encrypted'
+};
+
+// An E2E message renders the encrypted icon.
+export const Encrypted = () => (
+
+
+
+);
+
+// A non-encrypted message renders nothing.
+export const EncryptedHidden = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/MessageError.stories.tsx b/app/containers/message/components/stories/RightIcons/MessageError.stories.tsx
new file mode 100644
index 00000000000..a79b5be6508
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/MessageError.stories.tsx
@@ -0,0 +1,57 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { messagesStatus } from '../../../../../lib/constants/messagesStatus';
+import { MessageRoomProvider, type MessageRoomState } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import MessageErrorLeaf from '../../RightIcons/MessageError';
+
+const store = createMockedStore();
+
+const errorItem = {
+ id: 'msg-id',
+ msg: 'Message that failed to send',
+ status: messagesStatus.ERROR,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const okItem = {
+ id: 'msg-id',
+ msg: 'Message that sent fine',
+ status: messagesStatus.SENT,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ errorActionsShow: () => {}
+};
+
+const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/MessageError'
+};
+
+// A message in error status renders the warning icon.
+export const MessageError = () => (
+
+
+
+);
+
+// A message without an error renders nothing.
+export const MessageErrorHidden = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/Pinned.stories.tsx b/app/containers/message/components/stories/RightIcons/Pinned.stories.tsx
new file mode 100644
index 00000000000..2f67013ee71
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/Pinned.stories.tsx
@@ -0,0 +1,52 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { MessageRoomProvider } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import PinnedLeaf from '../../RightIcons/Pinned';
+
+const store = createMockedStore();
+
+const pinnedItem = {
+ id: 'msg-id',
+ msg: 'Pinned message',
+ pinned: true,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const unpinnedItem = {
+ id: 'msg-id',
+ msg: 'Regular message',
+ pinned: false,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({ item, children }: { item: TAnyMessageModel; children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/Pinned'
+};
+
+// A pinned message renders the pin icon.
+export const Pinned = () => (
+
+
+
+);
+
+// A non-pinned message renders nothing.
+export const PinnedHidden = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/ReadReceipt.stories.tsx b/app/containers/message/components/stories/RightIcons/ReadReceipt.stories.tsx
new file mode 100644
index 00000000000..31e81407f28
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/ReadReceipt.stories.tsx
@@ -0,0 +1,67 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import ReadReceiptLeaf from '../../RightIcons/ReadReceipt';
+
+const store = createMockedStore();
+
+const readItem = {
+ id: 'msg-id',
+ msg: 'Read message',
+ unread: false,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const unreadItem = {
+ id: 'msg-id',
+ msg: 'Unread message',
+ unread: true,
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({
+ item,
+ room,
+ children
+}: {
+ item: TAnyMessageModel;
+ room: Partial;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/ReadReceipt'
+};
+
+// With read receipts enabled, a read message renders the double-check icon.
+export const ReadReceipt = () => (
+
+
+
+);
+
+// With read receipts enabled, an unread message renders the single-check icon.
+export const ReadReceiptUnread = () => (
+
+
+
+);
+
+// With read receipts disabled, nothing renders.
+export const ReadReceiptHidden = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/RightIcons/Translated.stories.tsx b/app/containers/message/components/stories/RightIcons/Translated.stories.tsx
new file mode 100644
index 00000000000..969bf65ad0b
--- /dev/null
+++ b/app/containers/message/components/stories/RightIcons/Translated.stories.tsx
@@ -0,0 +1,61 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../../stores/MessageRoomStore';
+import { MessageProvider } from '../../../stores/MessageStore';
+import TranslatedLeaf from '../../RightIcons/Translated';
+
+const store = createMockedStore();
+
+const translatedItem = {
+ id: 'msg-id',
+ msg: 'Original message',
+ autoTranslate: true,
+ translations: [{ _id: 'translation-1', language: 'en', value: 'Translated text' }],
+ u: { _id: 'author-id', username: 'other.user' }
+} as unknown as TAnyMessageModel;
+
+const plainItem = {
+ id: 'msg-id',
+ msg: 'Original message',
+ autoTranslate: false,
+ u: { _id: 'author-id', username: 'other.user' }
+} as unknown as TAnyMessageModel;
+
+const StoryWrapper = ({
+ item,
+ room,
+ children
+}: {
+ item: TAnyMessageModel;
+ room: Partial;
+ children: ReactNode;
+}) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/RightIcons/Translated'
+};
+
+// 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
new file mode 100644
index 00000000000..96f659d6a67
--- /dev/null
+++ b/app/containers/message/components/stories/Thread.stories.tsx
@@ -0,0 +1,45 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import ThreadLeaf from '../Thread';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'Thread parent message',
+ tcount: 3,
+ tlm: new Date('2024-01-01T10:20:00.000Z'),
+ replies: ['user-1', 'user-2', 'user-3'],
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader' },
+ onThreadPress: () => {},
+ isThreadRoom: false
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Thread'
+};
+
+// A message with thread replies renders the View Thread button and thread details.
+export const Thread = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/Time.stories.tsx b/app/containers/message/components/stories/Time.stories.tsx
new file mode 100644
index 00000000000..2b06ced338f
--- /dev/null
+++ b/app/containers/message/components/stories/Time.stories.tsx
@@ -0,0 +1,41 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import MessageTimeLeaf from '../Time';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: '',
+ ts: new Date('2024-01-01T10:20:00.000Z'),
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ timeFormat: 'LT'
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Time'
+};
+
+// The timestamp renders the message time using the room time format.
+export const Time = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/Urls.stories.tsx b/app/containers/message/components/stories/Urls.stories.tsx
new file mode 100644
index 00000000000..790eafba882
--- /dev/null
+++ b/app/containers/message/components/stories/Urls.stories.tsx
@@ -0,0 +1,51 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { updateSettings } from '../../../../actions/settings';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import UrlsLeaf from '../Urls';
+
+const store = createMockedStore();
+store.dispatch(updateSettings('API_Embed', true));
+
+const item = {
+ id: 'msg-id',
+ msg: '',
+ u: { _id: 'author-id', username: 'rocket.cat' },
+ autoTranslate: false,
+ urls: [
+ {
+ url: 'https://rocket.chat',
+ title: 'Rocket.Chat',
+ description: 'Have your own chat service on-premises or in the cloud',
+ image: ''
+ }
+ ]
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader', token: 'token' },
+ baseUrl: 'https://open.rocket.chat'
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/Urls'
+};
+
+// A message with an embeddable URL renders the link preview card.
+export const Urls = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/User.stories.tsx b/app/containers/message/components/stories/User.stories.tsx
new file mode 100644
index 00000000000..8166af09f59
--- /dev/null
+++ b/app/containers/message/components/stories/User.stories.tsx
@@ -0,0 +1,45 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+
+import { createMockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import UserLeaf from '../User';
+
+const store = createMockedStore();
+
+const item = {
+ id: 'msg-id',
+ msg: 'Message',
+ t: undefined,
+ ts: new Date('2024-01-01T10:20:00.000Z'),
+ u: { _id: 'author-id', username: 'rocket.cat', name: 'Rocket Cat' },
+ alias: undefined,
+ role: undefined,
+ autoTranslate: false
+} as unknown as TAnyMessageModel;
+
+const room: Partial = {
+ user: { id: 'reader-id', username: 'reader' },
+ timeFormat: 'LT'
+};
+
+const StoryWrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+);
+
+export default {
+ title: 'Message/User'
+};
+
+// The header renders the username, timestamp and right icons row.
+export const User = () => (
+
+
+
+);
diff --git a/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap b/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap
new file mode 100644
index 00000000000..d56ec17f9da
--- /dev/null
+++ b/app/containers/message/components/stories/__tests__/__snapshots__/leaves.test.tsx.snap
@@ -0,0 +1,5588 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Story Snapshots: Broadcast should match snapshot 1`] = `
+
+
+
+
+
+
+
+ Reply
+
+
+
+
+`;
+
+exports[`Story Snapshots: BroadcastOwn should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: CallButton should match snapshot 1`] = `
+
+
+
+
+
+
+
+ Click to join!
+
+
+
+
+`;
+
+exports[`Story Snapshots: Content should match snapshot 1`] = `
+
+
+
+
+
+ This is a plain text message
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Default should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Discussion should match snapshot 1`] = `
+
+
+ Started a discussion:
+
+
+ Discussion topic message
+
+
+
+
+
+
+
+
+ 5 messages
+
+
+
+
+ January 1, 2024
+
+
+
+`;
+
+exports[`Story Snapshots: Edited should match snapshot 1`] = `
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: EditedHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: Emoji should match snapshot 1`] = `
+
+ 😀
+
+`;
+
+exports[`Story Snapshots: EmojiCustom should match snapshot 1`] = `
+
+`;
+
+exports[`Story Snapshots: Encrypted should match snapshot 1`] = `
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: EncryptedHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: GIF should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+ GIF
+
+
+
+
+`;
+
+exports[`Story Snapshots: Item should match snapshot 1`] = `
+
+
+
+
+
+
+ Engineering (9 today)
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Loading should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: MessageAvatar should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: MessageError should match snapshot 1`] = `
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: MessageErrorHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: MultipleImagesNewServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: MultipleImagesOldServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+ Image 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image 2
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Image 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Pinned should match snapshot 1`] = `
+
+
+
+`;
+
+exports[`Story Snapshots: PinnedHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: Reactions should match snapshot 1`] = `
+
+
+
+
+ 😂
+
+
+ 2
+
+
+
+
+
+
+ ❤️
+
+
+ 1
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: ReadReceipt should match snapshot 1`] = `
+
+
+
+`;
+
+exports[`Story Snapshots: ReadReceiptHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: ReadReceiptUnread should match snapshot 1`] = `
+
+
+
+`;
+
+exports[`Story Snapshots: RepliedThread should match snapshot 1`] = `
+
+
+
+
+
+
+
+ Original thread message
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: SingleImageNewServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ALT
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: SingleImageNoAlt should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: SingleImageOldServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+ A wavy orange and black pattern, designed to be used as a wallpaper
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Thread should match snapshot 1`] = `
+
+
+
+ View thread
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: Time should match snapshot 1`] = `
+
+ 10:20 AM
+
+`;
+
+exports[`Story Snapshots: Translated should match snapshot 1`] = `
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: TranslatedHidden should match snapshot 1`] = `null`;
+
+exports[`Story Snapshots: Urls should match snapshot 1`] = `
+
+
+
+ Rocket.Chat
+
+
+ Have your own chat service on-premises or in the cloud
+
+
+
+`;
+
+exports[`Story Snapshots: User should match snapshot 1`] = `
+
+
+
+ rocket.cat
+
+
+ 10:20 AM
+
+
+
+
+`;
+
+exports[`Story Snapshots: WithAltTextNewServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+`;
+
+exports[`Story Snapshots: WithAltTextOldServer should match snapshot 1`] = `
+
+
+
+
+
+
+
+
+`;
diff --git a/app/containers/message/components/stories/__tests__/leaves.test.tsx b/app/containers/message/components/stories/__tests__/leaves.test.tsx
new file mode 100644
index 00000000000..5120fa78c3d
--- /dev/null
+++ b/app/containers/message/components/stories/__tests__/leaves.test.tsx
@@ -0,0 +1,44 @@
+import { generateSnapshots } from '../../../../../../.rnstorybook/generateSnapshots';
+import * as attachments from '../Attachments.stories';
+import * as broadcast from '../Broadcast.stories';
+import * as callButton from '../CallButton.stories';
+import * as collapsibleQuote from '../CollapsibleQuote.stories';
+import * as content from '../Content.stories';
+import * as discussion from '../Discussion.stories';
+import * as emoji from '../Emoji.stories';
+import * as image from '../Image.stories';
+import * as messageAvatar from '../MessageAvatar.stories';
+import * as reactions from '../Reactions.stories';
+import * as repliedThread from '../RepliedThread.stories';
+import * as thread from '../Thread.stories';
+import * as time from '../Time.stories';
+import * as urls from '../Urls.stories';
+import * as user from '../User.stories';
+import * as edited from '../RightIcons/Edited.stories';
+import * as encrypted from '../RightIcons/Encrypted.stories';
+import * as messageError from '../RightIcons/MessageError.stories';
+import * as pinned from '../RightIcons/Pinned.stories';
+import * as readReceipt from '../RightIcons/ReadReceipt.stories';
+import * as translated from '../RightIcons/Translated.stories';
+
+generateSnapshots(attachments);
+generateSnapshots(broadcast);
+generateSnapshots(callButton);
+generateSnapshots(collapsibleQuote);
+generateSnapshots(content);
+generateSnapshots(discussion);
+generateSnapshots(emoji);
+generateSnapshots(image);
+generateSnapshots(messageAvatar);
+generateSnapshots(reactions);
+generateSnapshots(repliedThread);
+generateSnapshots(thread);
+generateSnapshots(time);
+generateSnapshots(urls);
+generateSnapshots(user);
+generateSnapshots(edited);
+generateSnapshots(encrypted);
+generateSnapshots(messageError);
+generateSnapshots(pinned);
+generateSnapshots(readReceipt);
+generateSnapshots(translated);
diff --git a/app/containers/message/docs/ARCHITECTURE.md b/app/containers/message/docs/ARCHITECTURE.md
new file mode 100644
index 00000000000..c6dfac99acd
--- /dev/null
+++ b/app/containers/message/docs/ARCHITECTURE.md
@@ -0,0 +1,104 @@
+# 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` + `'use memo'`
+
+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.). Components use the `'use memo'` directive (React Compiler, `babel-plugin-react-compiler`) rather than hand-written `useMemo`/`useCallback` for their own memoization.
+
+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 to 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`).
+- **`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__/downloadStatusReducer.test.ts b/app/containers/message/hooks/__tests__/downloadStatusReducer.test.ts
new file mode 100644
index 00000000000..e3d1ee04e1b
--- /dev/null
+++ b/app/containers/message/hooks/__tests__/downloadStatusReducer.test.ts
@@ -0,0 +1,23 @@
+import { downloadStatusReducer, type TDownloadEvent } from '../useMediaAutoDownload';
+import { type TDownloadState } from '../../../../lib/methods/handleMediaDownload';
+
+describe('downloadStatusReducer', () => {
+ const cases: Array<[TDownloadState, TDownloadEvent, TDownloadState]> = [
+ ['to-download', 'download_started', 'loading'],
+ ['loading', 'download_succeeded', 'downloaded'],
+ ['to-download', 'download_succeeded', 'downloaded'],
+ ['to-download', 'cache_hit', 'downloaded'],
+ ['loading', 'cache_hit', 'downloaded'],
+ ['loading', 'download_failed', 'to-download'],
+ ['loading', 'download_canceled', 'to-download'],
+ ['downloaded', 'download_started', 'loading']
+ ];
+
+ it.each(cases)('from %s on %s moves to %s', (from, event, to) => {
+ expect(downloadStatusReducer(from, event)).toBe(to);
+ });
+
+ it('ignores unknown events and keeps the current state', () => {
+ expect(downloadStatusReducer('to-download', 'unknown_event' as TDownloadEvent)).toBe('to-download');
+ });
+});
diff --git a/app/containers/message/hooks/__tests__/useFile.test.ts b/app/containers/message/hooks/__tests__/useFile.test.ts
new file mode 100644
index 00000000000..373fffe3315
--- /dev/null
+++ b/app/containers/message/hooks/__tests__/useFile.test.ts
@@ -0,0 +1,107 @@
+import { act, renderHook } from '@testing-library/react-native';
+
+import { useFile } from '../useFile';
+import { type IAttachment } from '../../../../definitions';
+import { getMessageById } from '../../../../lib/database/services/Message';
+import { getThreadMessageById } from '../../../../lib/database/services/ThreadMessage';
+
+jest.mock('../../../../lib/database/services/Message', () => ({
+ getMessageById: jest.fn()
+}));
+
+jest.mock('../../../../lib/database/services/ThreadMessage', () => ({
+ getThreadMessageById: jest.fn()
+}));
+
+const mockGetMessageById = getMessageById as jest.Mock;
+const mockGetThreadMessageById = getThreadMessageById as jest.Mock;
+
+const file = { title: 'original.png', title_link: '/original' } as IAttachment;
+
+// Flushes the async checkMessage effect (two awaited DB reads) inside act so any
+// setIsMessagePersisted update settles before assertions run.
+const flushEffect = () =>
+ act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+describe('useFile', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetThreadMessageById.mockResolvedValue(undefined);
+ mockGetMessageById.mockResolvedValue(undefined);
+ });
+
+ it('returns the file prop and treats forwarded merges as a no-op while the thread message persists', async () => {
+ mockGetThreadMessageById.mockResolvedValue({ id: 'thread-message' });
+ const { result } = renderHook(() => useFile(file, 'msg-id'));
+
+ await flushEffect();
+ expect(mockGetThreadMessageById).toHaveBeenCalledWith('msg-id');
+ expect(mockGetMessageById).not.toHaveBeenCalled();
+ expect(result.current[0]).toBe(file);
+
+ act(() => result.current[1]({ title_link: '/forwarded' }));
+ expect(result.current[0]).toBe(file);
+ });
+
+ it('stays persisted when no thread message exists but the message is found', async () => {
+ mockGetThreadMessageById.mockResolvedValue(undefined);
+ mockGetMessageById.mockResolvedValue({ id: 'message' });
+ const { result } = renderHook(() => useFile(file, 'msg-id'));
+
+ await flushEffect();
+ expect(mockGetMessageById).toHaveBeenCalledWith('msg-id');
+ expect(result.current[0]).toBe(file);
+
+ act(() => result.current[1]({ title_link: '/forwarded' }));
+ expect(result.current[0]).toBe(file);
+ });
+
+ it('becomes not-persisted and returns the merged localFile when neither message is found', async () => {
+ const { result } = renderHook(() => useFile(file, 'msg-id'));
+
+ await flushEffect();
+ expect(mockGetMessageById).toHaveBeenCalledWith('msg-id');
+
+ act(() => result.current[1]({ title_link: '/forwarded' }));
+ expect(result.current[0].title_link).toBe('/forwarded');
+ expect(result.current[0]).not.toBe(file);
+ expect(result.current[0].title).toBe('original.png');
+ });
+
+ it('starts not-persisted when messageId is empty and merges forwarded files into localFile', async () => {
+ const { result } = renderHook(() => useFile(file, ''));
+
+ await flushEffect();
+ expect(result.current[0]).toBe(file);
+
+ act(() => result.current[1]({ title_link: '/forwarded' }));
+ expect(result.current[0]).not.toBe(file);
+ expect(result.current[0].title_link).toBe('/forwarded');
+ });
+
+ // Documents the current contract: isMessagePersisted is seeded once and the effect only ever
+ // flips it to false, so it can't recover to true on a later messageId change. Not fixed: the
+ // only caller keys its list by that same id, so a real id change always remounts the hook fresh.
+ it('stays stuck not-persisted after rerendering with a real persisted messageId (documents current contract)', async () => {
+ mockGetThreadMessageById.mockResolvedValue(undefined);
+ mockGetMessageById.mockResolvedValue(undefined);
+ const { result, rerender } = renderHook(({ messageId }: { messageId: string }) => useFile(file, messageId), {
+ initialProps: { messageId: '' }
+ });
+
+ await flushEffect();
+ expect(result.current[0]).toBe(file);
+
+ mockGetMessageById.mockResolvedValue({ id: 'msg-id' });
+ rerender({ messageId: 'msg-id' });
+ await flushEffect();
+
+ expect(mockGetMessageById).toHaveBeenCalledWith('msg-id');
+ act(() => result.current[1]({ title_link: '/forwarded' }));
+ expect(result.current[0]).not.toBe(file);
+ expect(result.current[0].title_link).toBe('/forwarded');
+ });
+});
diff --git a/app/containers/message/hooks/useImageDescriptionLabel.test.ts b/app/containers/message/hooks/__tests__/useImageDescriptionLabel.test.ts
similarity index 95%
rename from app/containers/message/hooks/useImageDescriptionLabel.test.ts
rename to app/containers/message/hooks/__tests__/useImageDescriptionLabel.test.ts
index 759788da4b1..a22d91f5b1d 100644
--- a/app/containers/message/hooks/useImageDescriptionLabel.test.ts
+++ b/app/containers/message/hooks/__tests__/useImageDescriptionLabel.test.ts
@@ -1,9 +1,9 @@
import { renderHook } from '@testing-library/react-native';
-import { useImageDescriptionLabel } from './useImageDescriptionLabel';
-import { type IAttachment } from '../../../definitions';
+import { useImageDescriptionLabel } from '../useImageDescriptionLabel';
+import { type IAttachment } from '../../../../definitions';
-jest.mock('../../../lib/hooks/useAltTextSupported', () => ({
+jest.mock('../../../../lib/hooks/useAltTextSupported', () => ({
useAltTextSupported: () => false
}));
diff --git a/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx b/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx
new file mode 100644
index 00000000000..9fde89439df
--- /dev/null
+++ b/app/containers/message/hooks/__tests__/useMediaAutoDownload.test.tsx
@@ -0,0 +1,280 @@
+import { type ReactNode } from 'react';
+import { Provider } from 'react-redux';
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { useMediaAutoDownload } from '../useMediaAutoDownload';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type IAttachment, type IUserMessage, type TAnyMessageModel } from '../../../../definitions';
+import { cancelDownload, downloadMediaFile, getMediaCache, isDownloadActive } from '../../../../lib/methods/handleMediaDownload';
+import { fetchAutoDownloadEnabled } from '../../../../lib/methods/autoDownloadPreference';
+import { formatAttachmentUrl } from '../../../../lib/methods/helpers/formatAttachmentUrl';
+import { isImageBase64 } from '../../../../lib/methods/isImageBase64';
+import { emitter } from '../../../../lib/methods/helpers/emitter';
+
+jest.mock('../../../../lib/methods/handleMediaDownload', () => ({
+ downloadMediaFile: jest.fn(),
+ getMediaCache: jest.fn(),
+ isDownloadActive: jest.fn(),
+ cancelDownload: jest.fn()
+}));
+
+jest.mock('../../../../lib/methods/autoDownloadPreference', () => ({
+ fetchAutoDownloadEnabled: jest.fn()
+}));
+
+jest.mock('../../../../lib/methods/helpers/formatAttachmentUrl', () => ({
+ formatAttachmentUrl: jest.fn()
+}));
+
+jest.mock('../../../../lib/methods/isImageBase64', () => ({
+ isImageBase64: jest.fn()
+}));
+
+// Real mitt behind spies so resumeDownload registrations can be observed and the
+// download listener can be driven with emitter.emit.
+jest.mock('../../../../lib/methods/helpers/emitter', () => {
+ const mittModule = require('mitt');
+ const mitt = mittModule.default || mittModule;
+ const instance = mitt();
+ return {
+ emitter: {
+ on: jest.fn((type: string, handler: (uri: string) => void) => instance.on(type, handler)),
+ off: jest.fn((type: string, handler: (uri: string) => void) => instance.off(type, handler)),
+ emit: (type: string, event: string) => instance.emit(type, event)
+ }
+ };
+});
+
+// The persisted/localFile logic is covered by useFile.test; here useFile is a plain
+// state cell so setCurrentFile merges are observable through the returned currentFile.
+jest.mock('../useFile', () => {
+ const { useState } = require('react');
+ return {
+ useFile: (file: IAttachment) => {
+ const [current, setCurrent] = useState(file);
+ const manage = (partial: Partial) => setCurrent((prev: IAttachment) => ({ ...prev, ...partial }));
+ return [current, manage];
+ }
+ };
+});
+
+const mockDownloadMediaFile = downloadMediaFile as jest.Mock;
+const mockGetMediaCache = getMediaCache as jest.Mock;
+const mockIsDownloadActive = isDownloadActive as jest.Mock;
+const mockCancelDownload = cancelDownload as jest.Mock;
+const mockFetchAutoDownloadEnabled = fetchAutoDownloadEnabled as jest.Mock;
+const mockFormatAttachmentUrl = formatAttachmentUrl as jest.Mock;
+const mockIsImageBase64 = isImageBase64 as jest.Mock;
+const mockEmitterOn = emitter.on as jest.Mock;
+
+const URL = 'https://open.rocket.chat/file.png';
+const USER = { id: 'user-1', username: 'john', token: 'token' };
+
+// 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 = () =>
+ act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+const renderMediaHook = ({
+ file,
+ author,
+ showAttachment,
+ ctx = {}
+}: {
+ file: IAttachment;
+ author?: IUserMessage;
+ showAttachment?: (file: IAttachment) => void;
+ ctx?: Partial;
+}) => {
+ const contextValue: Partial = { baseUrl: 'https://open.rocket.chat', user: USER, ...ctx };
+ const item = { id: 'msg-1' } as unknown as TAnyMessageModel;
+ const wrapper = ({ children }: { children: ReactNode }) => (
+
+
+ {children}
+
+
+ );
+ return renderHook(() => useMediaAutoDownload({ file, author, showAttachment }), { wrapper });
+};
+
+describe('useMediaAutoDownload', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockFormatAttachmentUrl.mockReturnValue(URL);
+ mockIsImageBase64.mockReturnValue(false);
+ mockGetMediaCache.mockResolvedValue({ exists: false });
+ mockIsDownloadActive.mockReturnValue(false);
+ mockFetchAutoDownloadEnabled.mockReturnValue(false);
+ mockDownloadMediaFile.mockResolvedValue('file://downloaded');
+ });
+
+ describe('getFileType selection', () => {
+ it('resolves an image file to the image auto-download preference', async () => {
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await flushMount();
+ expect(mockFetchAutoDownloadEnabled).toHaveBeenCalledWith('imagePreferenceDownload');
+ expect(result.current.status).toBe('to-download');
+ });
+
+ it('resolves a video file to the video auto-download preference', async () => {
+ renderMediaHook({ file: { video_url: '/video' } });
+ await flushMount();
+ expect(mockFetchAutoDownloadEnabled).toHaveBeenCalledWith('videoPreferenceDownload');
+ });
+
+ it('resolves an audio file to the audio auto-download preference', async () => {
+ renderMediaHook({ file: { audio_url: '/audio' } });
+ await flushMount();
+ expect(mockFetchAutoDownloadEnabled).toHaveBeenCalledWith('audioPreferenceDownload');
+ });
+
+ it('defaults to the image preference when the file has no media url', async () => {
+ renderMediaHook({ file: {} });
+ await flushMount();
+ expect(mockFetchAutoDownloadEnabled).toHaveBeenCalledWith('imagePreferenceDownload');
+ });
+ });
+
+ describe('isEncrypted', () => {
+ it('is true when the current file e2e is pending', async () => {
+ const { result } = renderMediaHook({ file: { image_url: '/img', e2e: 'pending' } });
+ await flushMount();
+ expect(result.current.isEncrypted).toBe(true);
+ });
+
+ it('is false when the current file e2e is not pending', async () => {
+ const { result } = renderMediaHook({ file: { image_url: '/img', e2e: 'done' } });
+ await flushMount();
+ expect(mockGetMediaCache).toHaveBeenCalled();
+ expect(result.current.isEncrypted).toBe(false);
+ });
+ });
+
+ describe('mount effect', () => {
+ it('marks a base64 image as downloaded without hitting the cache', async () => {
+ mockIsImageBase64.mockReturnValue(true);
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+ expect(mockGetMediaCache).not.toHaveBeenCalled();
+ });
+
+ it('marks the file downloaded and updates title_link when a non-encrypted cache exists', async () => {
+ mockGetMediaCache.mockResolvedValue({ exists: true, uri: 'file://cache' });
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+ expect(result.current.currentFile.title_link).toBe('file://cache');
+ });
+
+ it('resumes an active download by registering the download listener and showing loading', async () => {
+ mockIsDownloadActive.mockReturnValue(true);
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+ expect(mockEmitterOn).toHaveBeenCalledWith(`downloadMedia${URL}`, expect.any(Function));
+ });
+ });
+
+ describe('tryAutoDownload', () => {
+ it('downloads when the auto-download preference is enabled', async () => {
+ mockFetchAutoDownloadEnabled.mockReturnValue(true);
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(mockDownloadMediaFile).toHaveBeenCalled());
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+ expect(result.current.currentFile.title_link).toBe('file://downloaded');
+ });
+
+ it('downloads when the author is the current user even if auto-download is disabled', async () => {
+ renderMediaHook({ file: { image_url: '/img' }, author: { _id: 'user-1' } as IUserMessage });
+ await waitFor(() => expect(mockDownloadMediaFile).toHaveBeenCalled());
+ });
+
+ it('stays in to-download when auto-download is disabled and the author is someone else', async () => {
+ const { result } = renderMediaHook({ file: { image_url: '/img' }, author: { _id: 'other' } as IUserMessage });
+ await flushMount();
+ expect(mockFetchAutoDownloadEnabled).toHaveBeenCalled();
+ expect(mockDownloadMediaFile).not.toHaveBeenCalled();
+ expect(result.current.status).toBe('to-download');
+ });
+
+ it('returns to to-download when the download fails', async () => {
+ mockFetchAutoDownloadEnabled.mockReturnValue(true);
+ mockDownloadMediaFile.mockRejectedValue(new Error('boom'));
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(mockDownloadMediaFile).toHaveBeenCalled());
+ await waitFor(() => expect(result.current.status).toBe('to-download'));
+ });
+
+ it('retries and succeeds after a previous download failure', async () => {
+ mockFetchAutoDownloadEnabled.mockReturnValue(true);
+ mockDownloadMediaFile.mockRejectedValueOnce(new Error('boom'));
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(mockDownloadMediaFile).toHaveBeenCalledTimes(1));
+ await waitFor(() => expect(result.current.status).toBe('to-download'));
+
+ await act(async () => result.current.onPress());
+ await waitFor(() => expect(mockDownloadMediaFile).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+ });
+ });
+
+ describe('onPress', () => {
+ it('cancels the download and returns to to-download while loading', async () => {
+ mockIsDownloadActive.mockReturnValue(true);
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+
+ act(() => result.current.onPress());
+ expect(mockCancelDownload).toHaveBeenCalledWith(URL);
+ expect(result.current.status).toBe('to-download');
+ });
+
+ it('starts a download when pressed in the to-download state', async () => {
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await flushMount();
+ expect(result.current.status).toBe('to-download');
+ expect(mockDownloadMediaFile).not.toHaveBeenCalled();
+
+ await act(async () => result.current.onPress());
+ expect(mockDownloadMediaFile).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the attachment when downloaded, unlocked and openable', async () => {
+ mockGetMediaCache.mockResolvedValue({ exists: true, uri: 'file://cache' });
+ const showAttachment = jest.fn();
+ const { result } = renderMediaHook({ file: { image_url: '/img' }, showAttachment });
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+
+ act(() => result.current.onPress());
+ expect(showAttachment).toHaveBeenCalledWith(expect.objectContaining({ title_link: 'file://cache' }));
+ });
+
+ it('does not open the attachment when there is no showAttachment handler', async () => {
+ mockGetMediaCache.mockResolvedValue({ exists: true, uri: 'file://cache' });
+ const { result } = renderMediaHook({ file: { image_url: '/img' } });
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+
+ act(() => result.current.onPress());
+ expect(result.current.status).toBe('downloaded');
+ });
+
+ it('does not open the attachment while it is still encrypted', async () => {
+ mockIsDownloadActive.mockReturnValue(true);
+ const showAttachment = jest.fn();
+ const { result } = renderMediaHook({ file: { image_url: '/img', e2e: 'pending' }, showAttachment });
+ await waitFor(() => expect(result.current.status).toBe('loading'));
+
+ act(() => emitter.emit(`downloadMedia${URL}`, 'file://encrypted'));
+ await waitFor(() => expect(result.current.status).toBe('downloaded'));
+ expect(result.current.isEncrypted).toBe(true);
+
+ act(() => result.current.onPress());
+ expect(showAttachment).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/containers/message/hooks/useMessageAccessibilityActions.test.ts b/app/containers/message/hooks/__tests__/useMessageAccessibilityActions.test.ts
similarity index 86%
rename from app/containers/message/hooks/useMessageAccessibilityActions.test.ts
rename to app/containers/message/hooks/__tests__/useMessageAccessibilityActions.test.ts
index 8be877613bd..0c955a10fd5 100644
--- a/app/containers/message/hooks/useMessageAccessibilityActions.test.ts
+++ b/app/containers/message/hooks/__tests__/useMessageAccessibilityActions.test.ts
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react-native';
-import { useMessageAccessibilityActions } from './useMessageAccessibilityActions';
+import { useMessageAccessibilityActions } from '../useMessageAccessibilityActions';
describe('useMessageAccessibilityActions', () => {
it('returns undefined when disabled', () => {
diff --git a/app/containers/message/hooks/__tests__/useMessageAccessibilityHint.test.tsx b/app/containers/message/hooks/__tests__/useMessageAccessibilityHint.test.tsx
new file mode 100644
index 00000000000..16ae3bcc066
--- /dev/null
+++ b/app/containers/message/hooks/__tests__/useMessageAccessibilityHint.test.tsx
@@ -0,0 +1,47 @@
+import { render } from '@testing-library/react-native';
+import { Provider } from 'react-redux';
+
+import { useMessageAccessibilityHint } from '../useMessageAccessibilityHint';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { MessageProvider } from '../../stores/MessageStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { mockedStore } from '../../../../reducers/mockedStore';
+
+const buildItem = (overrides: Partial = {}): TAnyMessageModel =>
+ ({ id: 'msg-1', ...overrides } as TAnyMessageModel);
+
+const renderHint = (item: TAnyMessageModel, config: Partial = {}) => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAccessibilityHint());
+ return null;
+ };
+ render(
+
+
+
+
+
+
+
+ );
+ const { calls } = spy.mock;
+ return calls[calls.length - 1][0];
+};
+
+describe('useMessageAccessibilityHint', () => {
+ it('returns the view thread hint when the message has a thread', () => {
+ const result = renderHint(buildItem({ tlm: new Date(), tcount: 1 }), { isThreadRoom: false });
+ expect(result).toBe('Press to view thread');
+ });
+
+ it('returns undefined when there is no thread', () => {
+ const result = renderHint(buildItem({ tlm: undefined, tcount: null }), { isThreadRoom: false });
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined when rendered inside a thread room', () => {
+ const result = renderHint(buildItem({ tlm: new Date(), tcount: 1 }), { isThreadRoom: true });
+ expect(result).toBeUndefined();
+ });
+});
diff --git a/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx b/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx
new file mode 100644
index 00000000000..f4352341876
--- /dev/null
+++ b/app/containers/message/hooks/__tests__/useMessageAccessibilityLabel.test.tsx
@@ -0,0 +1,146 @@
+import { Provider } from 'react-redux';
+import { render } from '@testing-library/react-native';
+
+import { useMessageAccessibilityLabel } from '../useMessageAccessibilityLabel';
+import { MessageProvider } from '../../stores/MessageStore';
+import { MessageRoomProvider, type MessageRoomState } from '../../stores/MessageRoomStore';
+import { updateSettings } from '../../../../actions/settings';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { type TAnyMessageModel } from '../../../../definitions';
+import { E2E_MESSAGE_TYPE } from '../../../../lib/constants/keys';
+
+jest.mock('../../../../lib/hooks/useAltTextSupported', () => ({
+ useAltTextSupported: () => false
+}));
+
+const FIXED_TS = new Date('2024-01-15T12:34:56Z');
+const HOUR = FIXED_TS.toLocaleTimeString();
+
+const buildItem = (overrides: Partial = {}): TAnyMessageModel =>
+ ({
+ id: 'msg-1',
+ msg: 'hello world',
+ ts: FIXED_TS,
+ u: { _id: 'u1', username: 'alice', name: 'Alice' },
+ unread: false,
+ ...overrides
+ } as TAnyMessageModel);
+
+const renderLabel = (item: TAnyMessageModel, config: Partial = {}, previousItem?: TAnyMessageModel) => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAccessibilityLabel());
+ return null;
+ };
+ render(
+
+
+
+
+
+
+
+ );
+ const { calls } = spy.mock;
+ return calls[calls.length - 1][0];
+};
+
+describe('useMessageAccessibilityLabel', () => {
+ it('builds the default label with user, hour and message', () => {
+ expect(renderLabel(buildItem())).toBe(`alice ${HOUR} hello world.`);
+ });
+
+ it('prefixes the message with "thread message" when the message has a tmid', () => {
+ expect(renderLabel(buildItem({ tmid: 't1' }))).toBe(`alice ${HOUR} thread message hello world.`);
+ });
+
+ it('replaces the body with the encrypted placeholder and appends it to the suffix', () => {
+ expect(renderLabel(buildItem({ t: E2E_MESSAGE_TYPE, e2e: 'pending' }))).toBe(
+ `alice ${HOUR} Encrypted message. Encrypted message`
+ );
+ });
+
+ it('uses the author real name when useRealName is true', () => {
+ mockedStore.dispatch(updateSettings('UI_Use_Real_Name', true));
+ try {
+ expect(renderLabel(buildItem())).toBe(`Alice ${HOUR} hello world.`);
+ } finally {
+ mockedStore.dispatch(updateSettings('UI_Use_Real_Name', false));
+ }
+ });
+
+ it('omits the hour when ts is missing', () => {
+ expect(renderLabel(buildItem({ ts: undefined }))).toBe('alice hello world.');
+ });
+
+ it('strips @username and #channel sigils via mentions/channels lists', () => {
+ expect(
+ renderLabel(
+ buildItem({
+ msg: 'hey @alice check #general',
+ mentions: [{ _id: 'u1', username: 'alice', name: 'Alice', type: 'user' }],
+ channels: [{ _id: 'c1', name: 'general' }]
+ })
+ )
+ ).toBe(`alice ${HOUR} hey alice check general.`);
+ });
+
+ it('appends "Message was read" when read receipts are enabled and the message is read', () => {
+ expect(renderLabel(buildItem({ unread: false }), { isReadReceiptEnabled: true })).toBe(
+ `alice ${HOUR} hello world. Message was read`
+ );
+ });
+
+ it('appends "Message was not read" when read receipts are enabled and the message is unread', () => {
+ expect(renderLabel(buildItem({ unread: true }), { isReadReceiptEnabled: true })).toBe(
+ `alice ${HOUR} hello world. Message was not read`
+ );
+ });
+
+ it('omits the read-receipt suffix for info messages', () => {
+ expect(renderLabel(buildItem({ t: 'uj', u: { _id: 'u1', username: 'alice' } }), { isReadReceiptEnabled: true })).toBe(
+ `alice ${HOUR} joined the channel.`
+ );
+ });
+
+ it('appends the image description to the suffix', () => {
+ expect(
+ renderLabel(
+ buildItem({ msg: 'caption', attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }] })
+ )
+ ).toBe(`alice ${HOUR} caption. Image description: A wavy pattern`);
+ });
+
+ it('does not announce "undefined" for attachment-only messages', () => {
+ expect(
+ renderLabel(
+ buildItem({ msg: undefined, attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }] })
+ )
+ ).toBe(`alice ${HOUR}. Image description: A wavy pattern`);
+ });
+
+ it('builds a translated message with only user, hour and translated marker as prefix', () => {
+ expect(
+ renderLabel(buildItem({ autoTranslate: true, translations: [{ _id: 't1', language: 'en', value: 'translated text' }] }), {
+ autoTranslateRoom: true,
+ autoTranslateLanguage: 'en',
+ user: { username: 'bob' }
+ })
+ ).toBe(`alice ${HOUR} Message translated into English.`);
+ });
+
+ it('still appends suffix metadata (image description, encryption, read receipt) on translated messages', () => {
+ expect(
+ renderLabel(
+ buildItem({
+ msg: 'caption',
+ autoTranslate: true,
+ translations: [{ _id: 't1', language: 'en', value: 'translated text' }],
+ unread: true,
+ attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }]
+ }),
+ { isReadReceiptEnabled: true, autoTranslateRoom: true, autoTranslateLanguage: 'en', user: { username: 'bob' } }
+ )
+ ).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 e681f8d3517..c3deec8da0f 100644
--- a/app/containers/message/hooks/useMediaAutoDownload.tsx
+++ b/app/containers/message/hooks/useMediaAutoDownload.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { useCallback, useEffect, useReducer } from 'react';
import { type IAttachment, type IUserMessage } from '../../../definitions';
import { isImageBase64 } from '../../../lib/methods/isImageBase64';
@@ -13,7 +13,8 @@ import {
} from '../../../lib/methods/handleMediaDownload';
import { emitter } from '../../../lib/methods/helpers/emitter';
import { formatAttachmentUrl } from '../../../lib/methods/helpers/formatAttachmentUrl';
-import MessageContext from '../Context';
+import { useBaseUrl, useMessageUser } from '../stores/MessageRoomStore';
+import { useMessageId } from '../stores/MessageStore';
import { useFile } from './useFile';
const getFileType = (file: IAttachment): MediaTypes | null => {
@@ -48,6 +49,23 @@ const getOriginalURL = (file: IAttachment): string | null => {
return null;
};
+export type TDownloadEvent = 'download_started' | 'download_succeeded' | 'download_failed' | 'download_canceled' | 'cache_hit';
+
+export const downloadStatusReducer = (state: TDownloadState, event: TDownloadEvent): TDownloadState => {
+ switch (event) {
+ case 'download_started':
+ return 'loading';
+ case 'download_succeeded':
+ case 'cache_hit':
+ return 'downloaded';
+ case 'download_failed':
+ case 'download_canceled':
+ return 'to-download';
+ default:
+ return state;
+ }
+};
+
export const useMediaAutoDownload = ({
file,
author,
@@ -55,20 +73,22 @@ export const useMediaAutoDownload = ({
}: {
file: IAttachment;
author?: IUserMessage;
- showAttachment?: Function;
+ showAttachment?: (file: IAttachment) => void;
}) => {
'use memo';
const fileType = getFileType(file) ?? 'image';
- const { id, baseUrl, user } = useContext(MessageContext);
- const [status, setStatus] = useState('to-download');
- const [currentFile, setCurrentFile] = useFile(file, id);
+ const id = useMessageId();
+ const baseUrl = useBaseUrl();
+ const user = useMessageUser();
+ const [status, dispatchDownloadEvent] = useReducer(downloadStatusReducer, 'to-download');
+ const [currentFile, setCurrentFile] = useFile(file, id ?? '');
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';
@@ -88,7 +108,7 @@ export const useMediaAutoDownload = ({
}
};
if (fileType === 'image' && isImageBase64(url)) {
- setStatus('downloaded');
+ dispatchDownloadEvent('cache_hit');
} else {
handleCache();
}
@@ -103,25 +123,23 @@ export const useMediaAutoDownload = ({
}, []);
const resumeDownload = () => {
- setStatus('loading');
+ dispatchDownloadEvent('download_started');
emitter.on(`downloadMedia${url}`, downloadMediaListener);
};
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();
- } else {
- setStatus('to-download');
}
};
const download = async () => {
try {
- setStatus('loading');
+ dispatchDownloadEvent('download_started');
const uri = await downloadMediaFile({
- messageId: id,
+ messageId: id ?? '',
downloadUrl: url,
type: fileType,
mimeType: getFileProperty(currentFile, fileType, 'type'),
@@ -131,7 +149,7 @@ export const useMediaAutoDownload = ({
setDecrypted();
updateCurrentFile(uri);
} catch (e) {
- setStatus('to-download');
+ dispatchDownloadEvent('download_failed');
}
};
@@ -139,7 +157,7 @@ export const useMediaAutoDownload = ({
setCurrentFile({
title_link: uri
});
- setStatus('downloaded');
+ dispatchDownloadEvent('download_succeeded');
};
const setDecrypted = () => {
@@ -165,7 +183,7 @@ export const useMediaAutoDownload = ({
const onPress = () => {
if (status === 'loading') {
cancelDownload(url);
- setStatus('to-download');
+ dispatchDownloadEvent('download_canceled');
return;
}
if (status === 'to-download') {
diff --git a/app/containers/message/hooks/useMessageAccessibilityHint.test.ts b/app/containers/message/hooks/useMessageAccessibilityHint.test.ts
deleted file mode 100644
index a93bf029caa..00000000000
--- a/app/containers/message/hooks/useMessageAccessibilityHint.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { renderHook } from '@testing-library/react-native';
-
-import { useMessageAccessibilityHint } from './useMessageAccessibilityHint';
-
-describe('useMessageAccessibilityHint', () => {
- it('returns the view thread hint when the message has a thread', () => {
- const { result } = renderHook(() => useMessageAccessibilityHint({ tlm: new Date(), tcount: 1, isThreadRoom: false }));
- expect(result.current).toBe('Press to view thread');
- });
-
- it('returns undefined when there is no thread', () => {
- const { result } = renderHook(() => useMessageAccessibilityHint({ tlm: undefined, tcount: null, isThreadRoom: false }));
- expect(result.current).toBeUndefined();
- });
-
- it('returns undefined when rendered inside a thread room', () => {
- const { result } = renderHook(() => useMessageAccessibilityHint({ tlm: new Date(), tcount: 1, isThreadRoom: true }));
- expect(result.current).toBeUndefined();
- });
-});
diff --git a/app/containers/message/hooks/useMessageAccessibilityHint.ts b/app/containers/message/hooks/useMessageAccessibilityHint.ts
index 15ace38b9f1..7d567c94038 100644
--- a/app/containers/message/hooks/useMessageAccessibilityHint.ts
+++ b/app/containers/message/hooks/useMessageAccessibilityHint.ts
@@ -1,11 +1,13 @@
import i18n from '../../../i18n';
-import { type IMessageThread } from '../interfaces';
+import { useIsThreadRoom } from '../stores/MessageRoomStore';
+import { useThreadData } from '../stores/MessageStore';
-export const useMessageAccessibilityHint = (
- props: Pick
-): string | undefined => {
+export const useMessageAccessibilityHint = (): string | undefined => {
'use memo';
- const hasThread = !!props.tlm && !props.isThreadRoom && props.tcount !== null;
+ const { tlm, tcount } = useThreadData();
+ const isThreadRoom = useIsThreadRoom();
+
+ const hasThread = !!tlm && !isThreadRoom && tcount !== null;
return hasThread ? i18n.t('A11y_press_to_view_thread') : undefined;
};
diff --git a/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts b/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts
deleted file mode 100644
index 227fe9d9f72..00000000000
--- a/app/containers/message/hooks/useMessageAccessibilityLabel.test.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { renderHook } from '@testing-library/react-native';
-
-import { useMessageAccessibilityLabel } from './useMessageAccessibilityLabel';
-import { type IMessage, type IMessageTouchable } from '../interfaces';
-
-jest.mock('../../../lib/hooks/useAltTextSupported', () => ({
- useAltTextSupported: () => false
-}));
-
-type TProps = IMessage & IMessageTouchable;
-
-const FIXED_TS = new Date('2024-01-15T12:34:56Z');
-const HOUR = FIXED_TS.toLocaleTimeString();
-
-const buildProps = (overrides: Partial = {}): TProps =>
- ({
- msg: 'hello world',
- ts: FIXED_TS,
- author: { _id: 'u1', username: 'alice', name: 'Alice' },
- isInfo: false,
- isThreadReply: false,
- isThreadSequential: false,
- isEncrypted: false,
- isTranslated: false,
- isReadReceiptEnabled: false,
- unread: false,
- useRealName: false,
- ...overrides
- } as TProps);
-
-describe('useMessageAccessibilityLabel', () => {
- it('builds the default label with user, hour and message', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps()));
- expect(result.current).toBe(`alice ${HOUR} hello world.`);
- });
-
- it('prefixes the message with "thread message" when tmid is set', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ tmid: 't1' })));
- expect(result.current).toBe(`alice ${HOUR} thread message hello world.`);
- });
-
- it('uses "replying to" for a thread reply', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isThreadReply: true })));
- expect(result.current).toBe(`alice ${HOUR} replying to hello world.`);
- });
-
- it('uses "replying to thread message" for a thread reply with tmid', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isThreadReply: true, tmid: 't1' })));
- expect(result.current).toBe(`alice ${HOUR} replying to thread message hello world.`);
- });
-
- it('uses "thread message" for a sequential thread message', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isThreadSequential: true })));
- expect(result.current).toBe(`alice ${HOUR} thread message hello world.`);
- });
-
- it('replaces the body with the encrypted placeholder and appends it to the suffix', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isEncrypted: true })));
- expect(result.current).toBe(`alice ${HOUR} Encrypted message. Encrypted message`);
- });
-
- it('uses the author real name when useRealName is true', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ useRealName: true })));
- expect(result.current).toBe(`Alice ${HOUR} hello world.`);
- });
-
- it('omits the hour when ts is missing', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ ts: undefined })));
- expect(result.current).toBe('alice hello world.');
- });
-
- it('strips @username and #channel sigils via mentions/channels lists', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(
- buildProps({
- msg: 'hey @alice check #general',
- mentions: [{ _id: 'u1', username: 'alice', name: 'Alice', type: 'user' }],
- channels: [{ _id: 'c1', name: 'general' }]
- })
- )
- );
- expect(result.current).toBe(`alice ${HOUR} hey alice check general.`);
- });
-
- it('appends "Message was read" when read receipts are enabled and the message is read', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isReadReceiptEnabled: true, unread: false })));
- expect(result.current).toBe(`alice ${HOUR} hello world. Message was read`);
- });
-
- it('appends "Message was not read" when read receipts are enabled and the message is unread', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isReadReceiptEnabled: true, unread: true })));
- expect(result.current).toBe(`alice ${HOUR} hello world. Message was not read`);
- });
-
- it('omits the read-receipt suffix for info messages', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(
- buildProps({ isReadReceiptEnabled: true, isInfo: true, type: 'uj', author: { _id: 'u1', username: 'alice' } })
- )
- );
- expect(result.current).toBe(`alice ${HOUR} joined the channel.`);
- });
-
- it('appends the image description to the suffix', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(
- buildProps({
- msg: 'caption',
- attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }]
- })
- )
- );
- expect(result.current).toBe(`alice ${HOUR} caption. Image description: A wavy pattern`);
- });
-
- it('does not announce "undefined" for attachment-only messages', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(
- buildProps({
- msg: undefined,
- attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }]
- })
- )
- );
- expect(result.current).toBe(`alice ${HOUR}. Image description: A wavy pattern`);
- });
-
- it('builds a translated message with only user, hour and translated marker as prefix', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(buildProps({ isTranslated: true, autoTranslateLanguage: 'en' }))
- );
- expect(result.current).toBe(`alice ${HOUR} Message translated into English.`);
- });
-
- it('still appends suffix metadata (image description, encryption, read receipt) on translated messages', () => {
- const { result } = renderHook(() =>
- useMessageAccessibilityLabel(
- buildProps({
- msg: 'caption',
- isTranslated: true,
- autoTranslateLanguage: 'en',
- isReadReceiptEnabled: true,
- unread: true,
- attachments: [{ image_url: 'https://example.com/img.png', altText: 'A wavy pattern' }]
- })
- )
- );
- expect(result.current).toBe(
- `alice ${HOUR} Message translated into English. Image description: A wavy pattern Message was not read`
- );
- });
-
- it('falls back to English when autoTranslateLanguage is missing', () => {
- const { result } = renderHook(() => useMessageAccessibilityLabel(buildProps({ isTranslated: true })));
- expect(result.current).toBe(`alice ${HOUR} Message translated into English.`);
- });
-});
diff --git a/app/containers/message/hooks/useMessageAccessibilityLabel.ts b/app/containers/message/hooks/useMessageAccessibilityLabel.ts
index bec1d4971aa..aa1f3c0ee7c 100644
--- a/app/containers/message/hooks/useMessageAccessibilityLabel.ts
+++ b/app/containers/message/hooks/useMessageAccessibilityLabel.ts
@@ -1,17 +1,29 @@
import i18n from '../../../i18n';
import translationLanguages from '../../../lib/constants/translationLanguages';
import { useImageDescriptionLabel } from './useImageDescriptionLabel';
-import { type IMessage, type IMessageTouchable } from '../interfaces';
import { getInfoMessage } from '../utils';
+import { type IUserChannel, type IUserMention } from '../../../definitions';
+import {
+ useContentData,
+ useIsEncrypted,
+ useIsInfoMessage,
+ useMessageAuthor,
+ useMessageHeaderMeta,
+ useMessageText,
+ useThreadData,
+ useThreadPosition
+} from '../stores/MessageStore';
+import { useAutoTranslate, useIsReadReceiptEnabled } from '../stores/MessageRoomStore';
+import { useSetting } from '../../../lib/hooks/useSetting';
-const stripMentions = (label: string, mentions: IMessage['mentions'] = [], channels: IMessage['channels'] = []) => {
+const stripMentions = (label: string, mentions: IUserMention[] = [], channels: IUserChannel[] = []) => {
let result = label;
- mentions.forEach(item => {
+ mentions?.forEach(item => {
if (item?.username) {
result = result.replaceAll(`@${item.username}`, item.username);
}
});
- channels.forEach(item => {
+ channels?.forEach(item => {
if (item?.name) {
result = result.replaceAll(`#${item.name}`, item.name);
}
@@ -19,36 +31,47 @@ const stripMentions = (label: string, mentions: IMessage['mentions'] = [], chann
return result;
};
-export const useMessageAccessibilityLabel = (props: IMessage & IMessageTouchable): string => {
- const imageDescriptionLabel = useImageDescriptionLabel(props.attachments, props.msg);
- const msg = props?.msg || '';
+export const useMessageAccessibilityLabel = (): string => {
+ const useRealName = useSetting('UI_Use_Real_Name') as boolean;
+ const { autoTranslateLanguage } = useAutoTranslate();
+ const { attachments, mentions, channels, comment, t: type } = useContentData();
+ const { u: author, role } = useMessageAuthor();
+ const { messageText, isTranslated } = useMessageText();
+ const { tmid } = useThreadData();
+ const { isThreadReply, isThreadSequential } = useThreadPosition();
+ const isInfo = useIsInfoMessage();
+ const isEncrypted = useIsEncrypted();
+ const { ts, unread } = useMessageHeaderMeta();
+ const isReadReceiptEnabled = useIsReadReceiptEnabled();
+
+ const imageDescriptionLabel = useImageDescriptionLabel(attachments, messageText);
+ const msg = messageText || '';
const threadMessageLabel = i18n.t('Thread_message', { msg });
- let label = props.isInfo ? msg : `${props.tmid ? threadMessageLabel : msg}`;
- if (props.isThreadReply) {
- label = i18n.t('Thread_reply', { msg: props.tmid ? threadMessageLabel : msg });
+ let label = isInfo ? msg : `${tmid ? threadMessageLabel : msg}`;
+ if (isThreadReply) {
+ label = i18n.t('Thread_reply', { msg: tmid ? threadMessageLabel : msg });
}
- if (props.isThreadSequential) {
+ if (isThreadSequential) {
label = threadMessageLabel;
}
- if (props.isEncrypted) {
+ if (isEncrypted) {
label = i18n.t('Encrypted_message');
}
- if (props.isInfo) {
- // @ts-ignore
- label = getInfoMessage({ ...props });
+ if (isInfo) {
+ label = getInfoMessage({ type, role, msg, author, comment });
}
- label = stripMentions(label, props.mentions, props.channels);
+ label = stripMentions(label, mentions, channels);
- const hour = props.ts ? new Date(props.ts).toLocaleTimeString() : '';
- const user = props.useRealName ? props.author?.name : props.author?.username || '';
- const readOrUnreadLabel = !props.unread && props.unread !== null ? i18n.t('Message_was_read') : i18n.t('Message_was_not_read');
- const readReceipt = props.isReadReceiptEnabled && !props.isInfo ? readOrUnreadLabel : '';
- const encryptedMessageLabel = props.isEncrypted ? i18n.t('Encrypted_message') : '';
- const translatedLanguage = translationLanguages[props?.autoTranslateLanguage || 'en'];
- const translated = props.isTranslated ? i18n.t('Message_translated_into_idiom', { idiom: translatedLanguage }) : '';
+ const hour = ts ? new Date(ts).toLocaleTimeString() : '';
+ const user = useRealName ? author?.name : author?.username || '';
+ const readOrUnreadLabel = !unread && unread !== null ? i18n.t('Message_was_read') : i18n.t('Message_was_not_read');
+ const readReceipt = isReadReceiptEnabled && !isInfo ? readOrUnreadLabel : '';
+ const encryptedMessageLabel = isEncrypted ? i18n.t('Encrypted_message') : '';
+ const translatedLanguage = translationLanguages[autoTranslateLanguage || 'en'];
+ const translated = isTranslated ? i18n.t('Message_translated_into_idiom', { idiom: translatedLanguage }) : '';
// For translated messages, the translated body is announced by the inner A11y.Index node, so the outer label
// only carries the metadata (user, hour, translated marker) and the suffix (image description, encryption, read receipt).
- const prefix = props.isTranslated
+ const prefix = isTranslated
? [user, hour, translated].filter(Boolean).join(' ')
: [user, hour, translated, label].filter(Boolean).join(' ');
const suffix = [imageDescriptionLabel, encryptedMessageLabel, readReceipt].filter(Boolean).join(' ');
diff --git a/app/containers/message/index.tsx b/app/containers/message/index.tsx
index 9f99526dfcb..37fff0f8caf 100644
--- a/app/containers/message/index.tsx
+++ b/app/containers/message/index.tsx
@@ -1,513 +1,39 @@
-import { Component } from 'react';
-import { Keyboard } from 'react-native';
+import { memo } from 'react';
-import Message from './Message';
-import MessageContext from './Context';
-import { debounce } from '../../lib/methods/helpers';
-import { getMessageTranslation } from './utils';
-import { type TSupportedThemes, withTheme } from '../../theme';
-import openLink from '../../lib/methods/helpers/openLink';
-import { type IAttachment, type TAnyMessageModel } from '../../definitions';
-import { type IRoomInfoParam } from '../../views/SearchMessagesView';
-import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/constants/keys';
-import { messagesStatus } from '../../lib/constants/messagesStatus';
+import MessageTouchable from './components/Touchable/MessageTouchable';
+import { type TAnyMessageModel } from '../../definitions';
import MessageSeparator from '../MessageSeparator';
+import { MessageProvider } from './stores/MessageStore';
interface IMessageContainerProps {
item: TAnyMessageModel;
- user: {
- id: string;
- username: string;
- token: string;
- };
- msg?: string;
- rid: string;
- timeFormat?: string;
- archived?: boolean;
- broadcast?: boolean;
previousItem?: TAnyMessageModel;
- baseUrl: string;
- Message_GroupingPeriod?: number;
- isReadReceiptEnabled?: boolean;
- isThreadRoom?: boolean;
- isSystemMessage?: boolean;
- useRealName?: boolean;
- autoTranslateRoom?: boolean;
- autoTranslateLanguage?: string;
- status?: number;
isIgnored?: boolean;
highlighted?: boolean;
onLongPress?: (item: TAnyMessageModel) => void;
- onReactionPress?: (emoji: string, id: string) => void;
- onEncryptedPress?: () => void;
- onDiscussionPress?: (item: TAnyMessageModel) => void;
- onThreadPress?: (item: TAnyMessageModel) => void;
- errorActionsShow?: (item: TAnyMessageModel) => void;
- replyBroadcast?: (item: TAnyMessageModel) => void;
- reactionInit?: (messageId: string) => void;
- fetchThreadName?: (tmid: string, id: string) => Promise;
- showAttachment?: (file: IAttachment) => void;
- onReactionLongPress?: (item: TAnyMessageModel) => void;
- navToRoomInfo?: (navParam: IRoomInfoParam) => void;
- handleEnterCall?: () => void;
- blockAction?: (params: { actionId: string; appId: string; value: string; blockId: string; rid: string; mid: string }) => void;
- onAnswerButtonPress?: Function;
threadBadgeColor?: string;
- toggleFollowThread?: (isFollowingThread: boolean, tmid?: string) => Promise;
- jumpToMessage?: (link: string) => void;
onPress?: () => void;
- theme?: TSupportedThemes;
- closeEmojiAndAction?: (action?: Function, params?: any) => void;
- isBeingEdited?: boolean;
isPreview?: boolean;
dateSeparator?: Date | string | null;
showUnreadSeparator?: boolean;
}
-interface IMessageContainerState {
- isManualUnignored: boolean;
-}
-
-class MessageContainer extends Component {
- static defaultProps = {
- onLongPress: () => {},
- blockAction: () => {},
- archived: false,
- broadcast: false,
- isIgnored: false,
- theme: 'light' as TSupportedThemes
- };
-
- state = { isManualUnignored: false };
-
- private subscription?: Function;
-
- componentDidMount() {
- const { item } = this.props;
- // @ts-ignore
- if (item && item.experimentalSubscribe) {
- // TODO: Update watermelonDB to recognize experimentalSubscribe at types
- // experimentalSubscribe(subscriber: (isDeleted: boolean) => void, debugInfo?: any): Unsubscribe
- // @ts-ignore
- this.subscription = item.experimentalSubscribe(() => {
- this.forceUpdate();
- });
- }
- }
-
- shouldComponentUpdate(nextProps: IMessageContainerProps, nextState: IMessageContainerState) {
- const { isManualUnignored } = this.state;
- const {
- threadBadgeColor,
- isIgnored,
- highlighted,
- previousItem,
- autoTranslateRoom,
- autoTranslateLanguage,
- isBeingEdited,
- showUnreadSeparator,
- dateSeparator
- } = this.props;
-
- if (nextProps.showUnreadSeparator !== showUnreadSeparator) {
- return true;
- }
- if (nextProps.dateSeparator !== dateSeparator) {
- return true;
- }
- if (nextProps.highlighted !== highlighted) {
- return true;
- }
- if (nextProps.threadBadgeColor !== threadBadgeColor) {
- return true;
- }
- if (nextProps.isIgnored !== isIgnored) {
- return true;
- }
- if (nextState.isManualUnignored !== isManualUnignored) {
- return true;
- }
- if (nextProps.previousItem?._id !== previousItem?._id) {
- return true;
- }
- if (isBeingEdited !== nextProps.isBeingEdited) {
- return true;
- }
- if (nextProps.autoTranslateRoom !== autoTranslateRoom) {
- return true;
- }
- if (nextProps.autoTranslateRoom !== autoTranslateRoom || nextProps.autoTranslateLanguage !== autoTranslateLanguage) {
- return true;
- }
- return false;
- }
-
- componentWillUnmount() {
- if (this.subscription) {
- this.subscription();
- }
- }
-
- onPressAction = () => {
- const { closeEmojiAndAction } = this.props;
-
- if (closeEmojiAndAction) {
- return closeEmojiAndAction(this.onPress);
- }
-
- return this.onPress();
- };
-
- onPress = debounce(
- () => {
- const { onPress } = this.props;
- if (this.isIgnored) {
- return this.onIgnoredMessagePress();
- }
-
- if (onPress) {
- return onPress();
- }
-
- const { item, isThreadRoom } = this.props;
- Keyboard.dismiss();
-
- if ((item.tlm || item.tmid) && !isThreadRoom) {
- this.onThreadPress();
- }
-
- const { onDiscussionPress } = this.props;
-
- if (item.dlm && onDiscussionPress) {
- onDiscussionPress(item);
- }
- },
- 300,
- true
+const MessageContainer = (props: IMessageContainerProps) => {
+ 'use memo';
+
+ const { item, previousItem, dateSeparator, showUnreadSeparator } = props;
+ return (
+
+
+
+
);
+};
- onLongPress = () => {
- const { archived, onLongPress, item } = this.props;
- if (this.isInfo || this.hasError || this.isEncrypted || archived) {
- return;
- }
- if (onLongPress) {
- onLongPress(item);
- }
- };
-
- onErrorPress = () => {
- const { errorActionsShow, item } = this.props;
- if (errorActionsShow) {
- errorActionsShow(item);
- }
- };
-
- onReactionPress = (emoji: string) => {
- const { onReactionPress, item } = this.props;
- if (onReactionPress) {
- onReactionPress(emoji, item.id);
- }
- };
-
- onReactionLongPress = () => {
- const { onReactionLongPress, item } = this.props;
- if (onReactionLongPress) {
- onReactionLongPress(item);
- }
- };
-
- onEncryptedPress = () => {
- const { onEncryptedPress } = this.props;
- if (onEncryptedPress) {
- onEncryptedPress();
- }
- };
-
- onDiscussionPress = () => {
- const { onDiscussionPress, item } = this.props;
- if (onDiscussionPress) {
- onDiscussionPress(item);
- }
- };
-
- onThreadPress = () => {
- const { onThreadPress, item } = this.props;
- if (onThreadPress) {
- onThreadPress(item);
- }
- };
-
- onAnswerButtonPress = (msg: string) => {
- const { onAnswerButtonPress } = this.props;
- if (onAnswerButtonPress) {
- onAnswerButtonPress(msg, false);
- }
- };
-
- onIgnoredMessagePress = () => {
- this.setState({ isManualUnignored: true });
- };
-
- get isHeader(): boolean {
- const { item, previousItem, broadcast, Message_GroupingPeriod } = this.props;
- if (this.hasError || (previousItem && previousItem.status === messagesStatus.ERROR)) {
- return true;
- }
- try {
- if (
- previousItem &&
- // @ts-ignore TODO: IMessage vs IMessageFromServer non-sense
- previousItem.ts.toDateString() === item.ts.toDateString() &&
- previousItem.u.username === item.u.username &&
- !(previousItem.groupable === false || item.groupable === false || broadcast === true) &&
- // @ts-ignore TODO: IMessage vs IMessageFromServer non-sense
- item.ts - previousItem.ts < Message_GroupingPeriod * 1000 &&
- previousItem.tmid === item.tmid &&
- item.t !== 'rm' &&
- previousItem.t !== 'rm'
- ) {
- return false;
- }
- return true;
- } catch (error) {
- return true;
- }
- }
-
- get isThreadReply(): boolean {
- const { item, previousItem, isThreadRoom } = this.props;
- if (isThreadRoom) {
- return false;
- }
- if (previousItem && item.tmid && previousItem.tmid !== item.tmid && previousItem.id !== item.tmid) {
- return true;
- }
- return false;
- }
-
- get isThreadSequential(): boolean {
- const { item, isThreadRoom } = this.props;
- if (isThreadRoom) {
- return false;
- }
- return !!item.tmid;
- }
-
- get isEncrypted(): boolean {
- const { item } = this.props;
- const { t, e2e } = item;
- return t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE;
- }
-
- get isInfo(): string | boolean {
- const { item } = this.props;
- if (['e2e', 'discussion-created', 'jitsi_call_started', 'videoconf'].includes(item.t)) {
- return false;
- }
- return item.t;
- }
-
- get isTemp(): boolean {
- const { item } = this.props;
- return item.status === messagesStatus.TEMP || item.status === messagesStatus.ERROR;
- }
-
- get isIgnored(): boolean {
- const { isManualUnignored } = this.state;
- const { isIgnored } = this.props;
- if (isManualUnignored) {
- return false;
- }
- return isIgnored ?? false;
- }
-
- get hasError(): boolean {
- const { item } = this.props;
- return item.status === messagesStatus.ERROR;
- }
-
- reactionInit = () => {
- const { reactionInit, item } = this.props;
- if (reactionInit) {
- reactionInit(item.id);
- }
- };
-
- replyBroadcast = () => {
- const { replyBroadcast, item } = this.props;
- if (replyBroadcast) {
- replyBroadcast(item);
- }
- };
-
- onLinkPress = (link: string): void => {
- const { item, jumpToMessage, theme } = this.props;
- const isMessageLink = item?.attachments?.findIndex((att: IAttachment) => att?.message_link === link) !== -1;
- if (isMessageLink && jumpToMessage) {
- return jumpToMessage(link);
- }
- openLink(link, theme);
- };
-
- render() {
- const {
- item,
- user,
- archived,
- baseUrl,
- useRealName,
- broadcast,
- fetchThreadName,
- showAttachment,
- timeFormat,
- isReadReceiptEnabled,
- autoTranslateRoom,
- autoTranslateLanguage,
- navToRoomInfo = () => {},
- isThreadRoom,
- handleEnterCall,
- blockAction,
- rid,
- threadBadgeColor,
- toggleFollowThread,
- jumpToMessage,
- highlighted,
- isBeingEdited,
- isPreview,
- showUnreadSeparator,
- dateSeparator
- } = this.props;
- const {
- id,
- msg,
- ts,
- attachments,
- urls,
- reactions,
- t,
- avatar,
- emoji,
- u,
- alias,
- editedBy,
- role,
- drid,
- dcount,
- dlm,
- tmid,
- tcount,
- tlm,
- tmsg,
- mentions,
- channels,
- unread,
- blocks,
- autoTranslate: autoTranslateMessage,
- replies,
- md,
- comment,
- pinned
- } = item;
-
- let message = msg;
- let isTranslated = false;
- const otherUserMessage = u?.username !== user?.username;
- // "autoTranslateRoom" and "autoTranslateLanguage" are properties from the subscription
- // "autoTranslateMessage" is a toggle between "View Original" and "Translate" state
- if (autoTranslateRoom && autoTranslateMessage && autoTranslateLanguage && otherUserMessage) {
- const messageTranslated = getMessageTranslation(item, autoTranslateLanguage);
- isTranslated = !!messageTranslated;
- message = messageTranslated || message;
- }
-
- const canTranslateMessage = autoTranslateRoom && autoTranslateLanguage && autoTranslateMessage !== false && otherUserMessage;
-
- return (
-
- {/* @ts-ignore*/}
-
-
-
- );
- }
-}
-
-export default withTheme(MessageContainer);
+export default memo(MessageContainer);
diff --git a/app/containers/message/interfaces.ts b/app/containers/message/interfaces.ts
index 4800bee0a17..fa73f9e4c94 100644
--- a/app/containers/message/interfaces.ts
+++ b/app/containers/message/interfaces.ts
@@ -1,75 +1,14 @@
-import { type Root } from '@rocket.chat/message-parser';
import { type StyleProp } from 'react-native';
import { type ImageStyle } from 'expo-image';
-import { type IUserChannel } from '../markdown/interfaces';
-import {
- type IAttachment,
- type IReaction,
- type IThread,
- type IUrl,
- type IUserMention,
- type IUserMessage,
- type MessageType,
- type TAnyMessageModel
-} from '../../definitions';
-import { type IRoomInfoParam } from '../../views/SearchMessagesView';
+import { type IAttachment } from '../../definitions';
export interface IMessageAttachments {
attachments?: IAttachment[];
- timeFormat?: string;
- showAttachment?: (file: IAttachment) => void;
- author?: IUserMessage;
}
export interface IMessageAvatar {
- isHeader: boolean;
- avatar?: string;
- emoji?: string;
- author?: IUserMessage;
small?: boolean;
- navToRoomInfo: (navParam: IRoomInfoParam) => void;
-}
-
-export interface IMessageBlocks {
- blocks: { appId?: string }[];
- id: string;
- rid: string;
- blockAction?: (params: { actionId: string; appId: string; value: string; blockId: string; rid: string; mid: string }) => void;
-}
-
-export interface IMessageBroadcast {
- author?: IUserMessage;
- broadcast?: boolean;
-}
-
-export interface IMessageCallButton {
- handleEnterCall?: () => void;
-}
-
-export interface IMessageContent {
- _id: string;
- isTemp: boolean;
- isInfo: string | boolean;
- tmid?: string;
- isThreadRoom: boolean;
- msg?: string;
- md?: Root;
- isEdited: boolean;
- isEncrypted: boolean;
- channels?: IUserChannel[];
- mentions?: IUserMention[];
- navToRoomInfo: (navParam: IRoomInfoParam) => void;
- useRealName?: boolean;
- isIgnored: boolean;
- type: MessageType;
- comment?: string;
- hasError: boolean;
- isHeader: boolean;
- isTranslated: boolean;
- pinned?: boolean;
- attachments?: IAttachment[];
- autoTranslateLanguage?: string;
}
export interface IMessageEmoji {
@@ -78,58 +17,6 @@ export interface IMessageEmoji {
customEmojiStyle: StyleProp;
}
-export interface IMessageThread extends Pick {
- isThreadRoom: boolean;
-}
-
-export interface IMessageTouchable {
- hasError: boolean;
- isInfo: string | boolean;
- isThreadReply: boolean;
- isTemp: boolean;
- archived?: boolean;
- highlighted?: boolean;
- ts?: string | Date;
- urls?: IUrl[];
- reactions?: any;
- alias?: string;
- role?: string;
- drid?: string;
- isBeingEdited?: boolean;
-}
-
-export interface IMessageRepliedThread extends Pick {
- isHeader: boolean;
- fetchThreadName?: (tmid: string, id: string) => Promise;
- isEncrypted: boolean;
-}
-
-export interface IMessageInner
- extends IMessageContent,
- IMessageCallButton,
- IMessageBlocks,
- IMessageThread,
- IMessageAttachments,
- IMessageBroadcast {
- type: MessageType;
- blocks: [];
- urls?: IUrl[];
- isPreview?: boolean;
- reactions?: IReaction[];
-}
-
-export interface IMessage extends IMessageRepliedThread, IMessageInner, IMessageAvatar {
- isThreadReply: boolean;
- isThreadSequential: boolean;
- isInfo: string | boolean;
- isTemp: boolean;
+export interface IMessageRepliedThread {
isHeader: boolean;
- hasError: boolean;
- onLongPress?: (item: TAnyMessageModel) => void;
- isReadReceiptEnabled?: boolean;
- unread?: boolean;
- isIgnored: boolean;
- dcount: number | undefined;
- dlm: string | Date | undefined;
- autoTranslateLanguage?: string;
}
diff --git a/app/containers/message/stores/MessageActionStore.tsx b/app/containers/message/stores/MessageActionStore.tsx
new file mode 100644
index 00000000000..a5b77f8c6a8
--- /dev/null
+++ b/app/containers/message/stores/MessageActionStore.tsx
@@ -0,0 +1,107 @@
+import { createContext, type ReactNode, useContext, useState } from 'react';
+import { createStore, useStore } from 'zustand';
+
+import { type TMessageActionState } from '../../../definitions';
+
+type TMessageActionActions = {
+ startEditing(messageId: string): void;
+ startQuote(messageId: string): void;
+ addQuote(messageId: string): void;
+ removeQuote(messageId: string): void;
+ startReacting(messageId: string): void;
+ setQuoteMessageIds(messageIds: string[]): void;
+ clear(): void;
+};
+
+type MessageActionState = {
+ action: TMessageActionState;
+ // Built once inside the store initializer — stable ref for consumers.
+ actions: TMessageActionActions;
+};
+
+export const createMessageActionStore = (initialAction?: TMessageActionState) =>
+ createStore()(set => ({
+ 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] } };
+ }),
+ removeQuote: messageId =>
+ set(state => {
+ if (state.action?.kind !== 'quote') {
+ return {};
+ }
+ const messageIds = state.action.messageIds.filter(m => m !== messageId);
+ return { action: messageIds.length ? { kind: 'quote', messageIds } : null };
+ }),
+ startReacting: messageId => set({ action: { kind: 'react', messageId } }),
+ setQuoteMessageIds: messageIds => set({ action: messageIds.length ? { kind: 'quote', messageIds } : null }),
+ clear: () => set({ action: null })
+ }
+ }));
+
+export type TMessageActionStore = ReturnType;
+
+export const MessageActionStoreContext = createContext(null);
+
+const noOpAction = (): never => {
+ throw new Error('MessageActionStore: no provider — actions unavailable');
+};
+
+const NO_OP_ACTIONS: TMessageActionActions = {
+ startEditing: noOpAction,
+ startQuote: noOpAction,
+ addQuote: noOpAction,
+ removeQuote: noOpAction,
+ startReacting: noOpAction,
+ setQuoteMessageIds: noOpAction,
+ clear: noOpAction
+};
+
+// 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 => {
+ const store = useContext(MessageActionStoreContext);
+ if (!store) {
+ throw new Error('Message action hooks must be used within a MessageActionProvider');
+ }
+ return useStore(store, selector);
+};
+
+// `action` is a single ref replaced wholesale on every `set` — no useShallow needed.
+export const useMessageAction = (): TMessageActionState => useMessageActionStore(s => s.action);
+
+/**
+ * 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);
+};
+
+export const MessageActionProvider = ({
+ store: externalStore,
+ initialAction,
+ children
+}: {
+ store?: TMessageActionStore;
+ initialAction?: TMessageActionState;
+ children: ReactNode;
+}): ReactNode => {
+ 'use memo';
+
+ const [store] = useState(() => externalStore ?? createMessageActionStore(initialAction));
+ return {children};
+};
diff --git a/app/containers/message/stores/MessageRoomStore.tsx b/app/containers/message/stores/MessageRoomStore.tsx
new file mode 100644
index 00000000000..d397c438ed4
--- /dev/null
+++ b/app/containers/message/stores/MessageRoomStore.tsx
@@ -0,0 +1,196 @@
+import { createContext, useContext, useEffect, useRef, 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 { useSetting } from '../../../lib/hooks/useSetting';
+
+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;
+ 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
+ rid?: string;
+ user?: { id?: string; username?: string; token?: string };
+ baseUrl?: string;
+ broadcast?: boolean;
+ isThreadRoom?: boolean;
+ Message_GroupingPeriod?: number;
+ timeFormat?: string;
+ // reactive tail (provider keeps current)
+ autoTranslateRoom?: boolean;
+ autoTranslateLanguage?: string;
+};
+
+export const createMessageRoomStore = (initial: MessageRoomState) => createStore(() => ({ ...initial }));
+
+export type MessageRoomStore = ReturnType;
+
+export const MessageRoomStoreContext = createContext(null);
+
+const useMessageRoomStore = (selector: (state: MessageRoomState) => T): T => {
+ const store = useContext(MessageRoomStoreContext);
+ if (!store) {
+ throw new Error('Message room hooks must be used within a MessageRoomProvider');
+ }
+ 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 useFrozenHandlersGuard: (state: MessageRoomState) => void = __DEV__
+ ? useFrozenHandlersGuardDev
+ : useFrozenHandlersGuardProd;
+
+const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
+ 'use memo';
+
+ const [store] = useState(() => createMessageRoomStore(state));
+ useFrozenHandlersGuard(state);
+
+ // 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,
+ autoTranslateRoom: state.autoTranslateRoom,
+ autoTranslateLanguage: state.autoTranslateLanguage,
+ archived: state.archived,
+ broadcast: state.broadcast,
+ isReadReceiptEnabled: state.isReadReceiptEnabled,
+ Message_GroupingPeriod: state.Message_GroupingPeriod
+ });
+ }, [
+ state.timeFormat,
+ state.autoTranslateRoom,
+ state.autoTranslateLanguage,
+ state.archived,
+ state.broadcast,
+ state.isReadReceiptEnabled,
+ state.Message_GroupingPeriod,
+ store
+ ]);
+
+ return {children};
+};
+
+const MessageRoomProviderWithSetting = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
+ 'use memo';
+
+ const Message_TimeFormat = useSetting('Message_TimeFormat') as string;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => {
+ 'use memo';
+
+ 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 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 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);
+export const useMessageGroupingPeriod = (): number | undefined => useMessageRoomStore(s => s.Message_GroupingPeriod);
+
+export const useAutoTranslate = (): { autoTranslateRoom?: boolean; autoTranslateLanguage?: string } =>
+ useMessageRoomStore(
+ useShallow(s => ({ autoTranslateRoom: s.autoTranslateRoom, autoTranslateLanguage: s.autoTranslateLanguage }))
+ );
diff --git a/app/containers/message/stores/MessageStore.tsx b/app/containers/message/stores/MessageStore.tsx
new file mode 100644
index 00000000000..e12f6a368fd
--- /dev/null
+++ b/app/containers/message/stores/MessageStore.tsx
@@ -0,0 +1,417 @@
+import { createContext, useContext, useEffect, useRef, useState, type ReactElement, type ReactNode } from 'react';
+import { createStore, useStore } from 'zustand';
+import { useShallow } from 'zustand/react/shallow';
+import { Keyboard } from 'react-native';
+
+import { type IAttachment, type TAnyMessageModel } from '../../../definitions';
+import { getMessageTranslation } 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';
+import openLink from '../../../lib/methods/helpers/openLink';
+import { useTheme } from '../../../theme';
+import {
+ useIsArchived,
+ useAutoTranslate,
+ useBroadcast,
+ useCloseEmojiAndAction,
+ useIsThreadRoom,
+ useJumpToMessage,
+ useMessageGroupingPeriod,
+ useMessageUser,
+ useOnDiscussionPress,
+ useOnThreadPress
+} from './MessageRoomStore';
+
+type MessageStoreState = {
+ tick: number;
+ item: TAnyMessageModel;
+ previousItem?: TAnyMessageModel;
+ isIgnored: boolean;
+ manualUnignored: boolean;
+ reveal: () => void;
+ onPress?: () => void;
+ onLongPress?: (item: TAnyMessageModel) => void;
+ threadBadgeColor?: string;
+};
+
+const createMessageStore = (
+ initial: Pick
+) =>
+ createStore(set => ({
+ tick: 0,
+ manualUnignored: false,
+ reveal: () => set({ manualUnignored: true }),
+ ...initial
+ }));
+
+type MessageStore = ReturnType;
+
+const MessageStoreContext = createContext(null);
+
+const useMessageStore = (selector: (state: MessageStoreState) => T): T => {
+ const store = useContext(MessageStoreContext);
+ if (!store) {
+ throw new Error('Message hooks must be used within a MessageProvider');
+ }
+ return useStore(store, selector);
+};
+
+export const useMessageItem = (): TAnyMessageModel => useMessageStore(s => s.item);
+
+// The selector must return a referentially stable value for an unchanged field (Object.is bail);
+// for multiple fields use a focused domain hook with useShallow, never call this hook repeatedly.
+// Stability of JSON fields depends on the model's @json(..., { memo: true }) decorators
+// (app/lib/database/model/Message.js); a field that drops memo:true returns a fresh ref every
+// access and would defeat both the Object.is and useShallow bail.
+// Guarded by app/lib/database/model/__tests__/Message.memo.test.ts.
+const isRecordOrArray = (value: unknown): value is Record | unknown[] =>
+ typeof value === 'object' && value !== null;
+
+const shallowEqual = (a: Record | unknown[], b: Record | unknown[]): boolean => {
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ return aKeys.every(key => Object.is((a as Record)[key], (b as Record)[key]));
+};
+
+const useMessageFieldProd = (selector: (item: TAnyMessageModel) => T): T => useMessageStore(s => selector(s.item));
+
+// Detects a selector that returns a fresh object/array every call (e.g. an inline `i => ({ a: i.a })`):
+// on an unchanged tick, a memoized field selector keeps returning the same reference (Object.is bail
+// holds), while a naive selector produces a new-but-shallow-equal value every time. Warns once.
+const useMessageFieldDev = (selector: (item: TAnyMessageModel) => T): T => {
+ const prevRef = useRef<{ tick: number; result: T } | undefined>(undefined);
+ const warnedRef = useRef(false);
+ return useMessageStore(s => {
+ const result = selector(s.item);
+ const prev = prevRef.current;
+ if (
+ !warnedRef.current &&
+ prev &&
+ prev.tick === s.tick &&
+ !Object.is(prev.result, result) &&
+ isRecordOrArray(prev.result) &&
+ isRecordOrArray(result) &&
+ shallowEqual(prev.result, result)
+ ) {
+ warnedRef.current = true;
+ console.warn(
+ '[MessageStore] useMessageField selector returned a new reference for unchanged data. ' +
+ 'Use a useShallow domain hook instead of an inline object/array selector.'
+ );
+ }
+ prevRef.current = { tick: s.tick, result };
+ return result;
+ });
+};
+
+export const useMessageField: (selector: (item: TAnyMessageModel) => T) => T = __DEV__
+ ? useMessageFieldDev
+ : useMessageFieldProd;
+
+// Plain REST objects (no experimentalSubscribe) never emit again after the initial render.
+const subscribeModel = (m: TAnyMessageModel, store: MessageStore) => {
+ if (typeof m.experimentalSubscribe !== 'function') return undefined;
+ return m.experimentalSubscribe(() => store.setState(s => ({ tick: s.tick + 1 })));
+};
+
+export const MessageProvider = ({
+ item,
+ previousItem,
+ onPress,
+ onLongPress,
+ threadBadgeColor,
+ isIgnored,
+ children
+}: {
+ item: TAnyMessageModel;
+ previousItem?: TAnyMessageModel;
+ onPress?: () => void;
+ onLongPress?: (item: TAnyMessageModel) => void;
+ threadBadgeColor?: string;
+ isIgnored?: boolean;
+ children: ReactNode;
+}): ReactElement => {
+ 'use memo';
+
+ const [store] = useState(() =>
+ createMessageStore({ item, previousItem, isIgnored: isIgnored ?? false, onPress, onLongPress, threadBadgeColor })
+ );
+
+ // Push item/previousItem into the store and (re)subscribe both records to the same tick.
+ // Resubscribing on either change is a single sync commit, so there's no observable churn.
+ useEffect(() => {
+ store.setState({ item, previousItem });
+ const unsubscribeItem = subscribeModel(item, store);
+ const unsubscribePrevious = previousItem ? subscribeModel(previousItem, store) : undefined;
+ return () => {
+ unsubscribeItem?.();
+ unsubscribePrevious?.();
+ };
+ }, [item, previousItem, store]);
+
+ // Mirror per-message row handlers so field-level selectors subscribe without churning the context value.
+ useEffect(() => {
+ store.setState({ onPress, onLongPress, threadBadgeColor });
+ }, [onPress, onLongPress, threadBadgeColor, store]);
+
+ // Reset the manual reveal only when the ignore state actually transitions.
+ useEffect(() => {
+ store.setState({ isIgnored: isIgnored ?? false, manualUnignored: false });
+ }, [isIgnored, store]);
+
+ return {children};
+};
+
+export const useReactions = (): TAnyMessageModel['reactions'] => useMessageField(item => item.reactions);
+
+export const useUrls = (): TAnyMessageModel['urls'] => useMessageField(item => item.urls);
+
+export const useBlocks = (): Pick =>
+ useMessageStore(useShallow(s => ({ blocks: s.item.blocks, id: s.item.id })));
+
+export const useDiscussion = (): Pick =>
+ useMessageStore(useShallow(s => ({ dcount: s.item.dcount, dlm: s.item.dlm })));
+
+export const useThreadData = (): Pick =>
+ useMessageStore(useShallow(s => ({ tcount: s.item.tcount, tlm: s.item.tlm, tmid: s.item.tmid, id: s.item.id })));
+
+export const useRepliedThreadData = (): Pick =>
+ useMessageStore(useShallow(s => ({ tmid: s.item.tmid, tmsg: s.item.tmsg, id: s.item.id })));
+
+export const useMessageAuthor = (): Pick =>
+ useMessageStore(useShallow(s => ({ u: s.item.u, alias: s.item.alias, role: s.item.role })));
+
+export const useIsOwnMessage = (): boolean => {
+ const author = useMessageStore(s => s.item.u);
+ const user = useMessageUser();
+ return author?._id === user?.id;
+};
+
+export const useAvatar = (): Pick =>
+ useMessageStore(useShallow(s => ({ avatar: s.item.avatar, emoji: s.item.emoji })));
+
+export const useContentData = (): Pick =>
+ useMessageStore(
+ useShallow(s => ({
+ md: s.item.md,
+ mentions: s.item.mentions,
+ channels: s.item.channels,
+ comment: s.item.comment,
+ attachments: s.item.attachments,
+ t: s.item.t
+ }))
+ );
+
+export const useInfoData = (): Pick =>
+ useMessageStore(useShallow(s => ({ t: s.item.t, comment: s.item.comment })));
+
+export const useMarkdownData = (): Pick =>
+ useMessageStore(useShallow(s => ({ md: s.item.md, mentions: s.item.mentions, channels: s.item.channels, t: s.item.t })));
+
+export const useAttachments = (): TAnyMessageModel['attachments'] => useMessageField(item => item.attachments);
+
+export const useMessageHeaderMeta = (): Pick =>
+ useMessageStore(useShallow(s => ({ ts: s.item.ts, unread: s.item.unread, pinned: s.item.pinned, t: s.item.t })));
+
+const computeIsHeader = (
+ prev: TAnyMessageModel | undefined,
+ item: TAnyMessageModel,
+ broadcast: boolean,
+ Message_GroupingPeriod: number | undefined,
+ hasError: boolean
+): boolean => {
+ if (hasError || (prev && prev.status === messagesStatus.ERROR)) {
+ return true;
+ }
+ // Grouping only applies to DB models with a Date ts; REST-sourced messages carry a string ts and are always headers.
+ if (!prev || Message_GroupingPeriod == null || !(prev.ts instanceof Date) || !(item.ts instanceof Date)) {
+ return true;
+ }
+ if (
+ prev.ts.toDateString() === item.ts.toDateString() &&
+ prev.u?.username === item.u?.username &&
+ !(prev.groupable === false || item.groupable === false || broadcast === true) &&
+ item.ts.getTime() - prev.ts.getTime() < Message_GroupingPeriod * 1000 &&
+ prev.tmid === item.tmid &&
+ item.t !== 'rm' &&
+ prev.t !== 'rm'
+ ) {
+ return false;
+ }
+ return true;
+};
+
+export const useMessageGrouping = (): boolean => {
+ const broadcast = useBroadcast();
+ const Message_GroupingPeriod = useMessageGroupingPeriod();
+ return useMessageStore(s =>
+ computeIsHeader(s.previousItem, s.item, !!broadcast, Message_GroupingPeriod, s.item.status === messagesStatus.ERROR)
+ );
+};
+
+export const useThreadPosition = (): { isThreadReply: boolean; isThreadSequential: boolean } => {
+ const isThreadRoom = useIsThreadRoom();
+ return useMessageStore(
+ useShallow(s => {
+ if (isThreadRoom) {
+ return { isThreadReply: false, isThreadSequential: false };
+ }
+ const isThreadReply = !!(
+ s.previousItem &&
+ s.item.tmid &&
+ s.previousItem.tmid !== s.item.tmid &&
+ s.previousItem.id !== s.item.tmid
+ );
+ return { isThreadReply, isThreadSequential: !!s.item.tmid };
+ })
+ );
+};
+
+export const useMessageStatus = (): { hasError: boolean; isTemp: boolean } =>
+ useMessageStore(
+ useShallow(s => ({
+ hasError: s.item.status === messagesStatus.ERROR,
+ isTemp: s.item.status === messagesStatus.TEMP || s.item.status === messagesStatus.ERROR
+ }))
+ );
+
+export const useIsEncrypted = (): boolean => useMessageField(item => item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE);
+
+export const useIsInfoMessage = (): boolean =>
+ useMessageField(item => {
+ if (['e2e', 'discussion-created', 'jitsi_call_started', 'videoconf'].includes(item.t as string)) {
+ return false;
+ }
+ return !!item.t;
+ });
+
+export const useIsEdited = (): boolean => useMessageField(item => (item.editedBy && !!item.editedBy.username) ?? false);
+
+export const useMessageId = (): TAnyMessageModel['id'] => useMessageField(item => item.id);
+
+export const useReplies = (): TAnyMessageModel['replies'] => useMessageField(item => item.replies);
+
+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;
+ });
+};
+
+export const useMessageText = (): { messageText: TAnyMessageModel['msg']; isTranslated: boolean } => {
+ const user = useMessageUser();
+ const { autoTranslateRoom, autoTranslateLanguage } = useAutoTranslate();
+ return useMessageStore(
+ 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);
+ isTranslated = !!translated;
+ messageText = translated || messageText;
+ }
+ return { messageText, isTranslated };
+ })
+ );
+};
+
+export const useThreadBadgeColor = (): string | undefined => useMessageStore(s => s.threadBadgeColor);
+
+export const useMessageIgnored = (): boolean => useMessageStore(s => (s.manualUnignored ? false : s.isIgnored));
+
+export const useRevealIgnored = (): (() => void) => useMessageStore(s => s.reveal);
+
+// Single source of truth for pressability, shared by the Touch gate, long-press guard and
+// press guard. longPressable drops encrypted messages (tap can still open a thread; the action
+// sheet is suppressed); revealsIgnored is tappable ∧ isIgnored (a tap reveals instead of pressing).
+export const useMessageTouchable = (): { tappable: boolean; longPressable: boolean; revealsIgnored: boolean } => {
+ 'use memo';
+
+ const isInfo = useIsInfoMessage();
+ const { hasError, isTemp } = useMessageStatus();
+ const isEncrypted = useIsEncrypted();
+ const archived = useIsArchived();
+ const isIgnored = useMessageIgnored();
+ const type = useMessageField(item => item.t);
+
+ const tappable = !(isInfo || hasError || archived || isTemp || type === 'jitsi_call_started');
+ const longPressable = tappable && !isEncrypted;
+ const revealsIgnored = tappable && isIgnored;
+
+ return { tappable, longPressable, revealsIgnored };
+};
+
+export const useMessageLongPress = (): (() => void) => {
+ 'use memo';
+
+ const item = useMessageItem();
+ const { longPressable } = useMessageTouchable();
+ const onLongPress = useMessageStore(s => s.onLongPress);
+ return () => {
+ if (!longPressable) {
+ return;
+ }
+ onLongPress?.(item);
+ };
+};
+
+export const useOnLinkPress = (): ((link: string) => void) => {
+ 'use memo';
+
+ const item = useMessageItem();
+ const jumpToMessage = useJumpToMessage();
+ const { theme } = useTheme();
+ return (link: string) => {
+ const isMessageLink = !!item.attachments?.some((att: IAttachment) => att?.message_link === link);
+ if (isMessageLink && jumpToMessage) {
+ return jumpToMessage(link);
+ }
+ openLink(link, theme);
+ };
+};
+
+export const useMessagePress = (): (() => void) => {
+ 'use memo';
+
+ const item = useMessageItem();
+ const isThreadRoom = useIsThreadRoom();
+ const onPress = useMessageStore(s => s.onPress);
+ const onThreadPress = useOnThreadPress();
+ const onDiscussionPress = useOnDiscussionPress();
+ const closeEmojiAndAction = useCloseEmojiAndAction();
+ const { revealsIgnored } = useMessageTouchable();
+ const revealIgnored = useRevealIgnored();
+
+ const handlePress = useDebounce(
+ () => {
+ if (revealsIgnored) {
+ return revealIgnored();
+ }
+ if (onPress) {
+ return onPress();
+ }
+ Keyboard.dismiss();
+ if ((item.tlm || item.tmid) && !isThreadRoom) {
+ onThreadPress?.(item);
+ }
+ if (item.dlm && onDiscussionPress) {
+ onDiscussionPress(item.drid);
+ }
+ },
+ 300,
+ { leading: true, trailing: false }
+ );
+
+ return () => {
+ if (closeEmojiAndAction) {
+ return closeEmojiAndAction(handlePress);
+ }
+ return handlePress();
+ };
+};
diff --git a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx
new file mode 100644
index 00000000000..a342618f4a4
--- /dev/null
+++ b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx
@@ -0,0 +1,268 @@
+import { render } from '@testing-library/react-native';
+
+import {
+ createMessageActionStore,
+ inertStore,
+ MessageActionProvider,
+ useIsBeingEdited,
+ useMessageAction
+} from '../MessageActionStore';
+
+describe('MessageActionStore', () => {
+ describe('useIsBeingEdited', () => {
+ it('returns false with no provider (inert store) and does not throw', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useIsBeingEdited('msg-1'));
+ return null;
+ };
+
+ expect(() => render()).not.toThrow();
+ expect(spy).toHaveBeenLastCalledWith(false);
+ });
+
+ it('does not share mutable state across separate no-provider renders, and its actions throw', () => {
+ const spyA = jest.fn();
+ const spyB = jest.fn();
+ const ProbeA = () => {
+ spyA(useIsBeingEdited('msg-1'));
+ return null;
+ };
+ const ProbeB = () => {
+ spyB(useIsBeingEdited('msg-1'));
+ return null;
+ };
+
+ render();
+ render();
+
+ 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', () => {
+ const editedSpy = jest.fn();
+ const otherSpy = jest.fn();
+ const Probe = () => {
+ editedSpy(useIsBeingEdited('msg-1'));
+ otherSpy(useIsBeingEdited('msg-2'));
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(editedSpy).toHaveBeenLastCalledWith(true);
+ expect(otherSpy).toHaveBeenLastCalledWith(false);
+ });
+
+ it('returns false for a quote action, even for a matching messageId', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useIsBeingEdited('msg-1'));
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith(false);
+ });
+
+ it('returns false for a react action, even for a matching messageId', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useIsBeingEdited('msg-1'));
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith(false);
+ });
+ });
+
+ describe('action hooks without a provider', () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('useMessageAction throws', () => {
+ const Probe = () => {
+ useMessageAction();
+ return null;
+ };
+ expect(() => render()).toThrow('Message action hooks must be used within a MessageActionProvider');
+ });
+ });
+
+ describe('useMessageAction under a provider', () => {
+ it('returns null when there is no action', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAction());
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith(null);
+ });
+
+ it('returns the edit action', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAction());
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith({ kind: 'edit', messageId: 'msg-1' });
+ });
+
+ it('returns the quote action', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAction());
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] });
+ });
+
+ it('returns the react action', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageAction());
+ return null;
+ };
+
+ render(
+
+
+
+ );
+
+ expect(spy).toHaveBeenLastCalledWith({ kind: 'react', messageId: 'msg-1' });
+ });
+ });
+
+ describe('action creators (union transitions)', () => {
+ it('startEditing sets an edit action with the given messageId', () => {
+ const store = createMessageActionStore();
+ store.getState().actions.startEditing('msg-1');
+ expect(store.getState().action).toEqual({ kind: 'edit', messageId: 'msg-1' });
+ });
+
+ it('startQuote starts a quote action with a single messageId', () => {
+ const store = createMessageActionStore();
+ store.getState().actions.startQuote('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');
+ 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');
+ 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('removeQuote 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', () => {
+ 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', () => {
+ 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', () => {
+ const store = createMessageActionStore();
+ store.getState().actions.startReacting('msg-1');
+ expect(store.getState().action).toEqual({ kind: 'react', messageId: 'msg-1' });
+ });
+
+ 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']);
+ expect(store.getState().action).toEqual({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] });
+
+ store.getState().actions.setQuoteMessageIds([]);
+ expect(store.getState().action).toBeNull();
+ });
+
+ it('clear resets a react action', () => {
+ const store = createMessageActionStore({ kind: 'react', messageId: 'msg-1' });
+ store.getState().actions.clear();
+ expect(store.getState().action).toBeNull();
+ });
+
+ it('clear resets an edit action', () => {
+ const store = createMessageActionStore({ kind: 'edit', messageId: 'msg-1' });
+ store.getState().actions.clear();
+ expect(store.getState().action).toBeNull();
+ });
+
+ it('clear resets a quote action', () => {
+ const store = createMessageActionStore({ kind: 'quote', messageIds: ['msg-1', 'msg-2'] });
+ store.getState().actions.clear();
+ expect(store.getState().action).toBeNull();
+ });
+ });
+});
diff --git a/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx
new file mode 100644
index 00000000000..9c5ee9304ef
--- /dev/null
+++ b/app/containers/message/stores/__tests__/MessageRoomStore.test.tsx
@@ -0,0 +1,200 @@
+import { memo } from 'react';
+import { act, render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import { Provider } from 'react-redux';
+
+import { MessageRoomProvider, useAutoTranslate, useIsArchived, useTimeFormat } from '../MessageRoomStore';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { updateSettings } from '../../../../actions/settings';
+
+describe('MessageRoomStore', () => {
+ describe('outside a MessageRoomProvider', () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('useIsArchived throws', () => {
+ const Probe = () => {
+ useIsArchived();
+ return null;
+ };
+ 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 = () => {
+ spy(useTimeFormat());
+ return null;
+ };
+ const wrap = (timeFormat: string) => (
+
+
+
+
+
+ );
+
+ const { rerender } = render(wrap('MMM Do YYYY'));
+ expect(spy).toHaveBeenLastCalledWith('MMM Do YYYY');
+
+ act(() => rerender(wrap('h:mm a')));
+ expect(spy).toHaveBeenLastCalledWith('h:mm a');
+ });
+
+ it('does not subscribe to Message_TimeFormat when a timeFormat prop is passed', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useTimeFormat());
+ return null;
+ };
+
+ render(
+
+
+
+
+
+ );
+ expect(spy).toHaveBeenLastCalledWith('literal-format');
+ const callsBefore = spy.mock.calls.length;
+
+ act(() => {
+ mockedStore.dispatch(updateSettings('Message_TimeFormat', 'h:mm a'));
+ });
+
+ expect(spy.mock.calls.length).toBe(callsBefore);
+ expect(spy).toHaveBeenLastCalledWith('literal-format');
+ });
+
+ it('uses the Message_TimeFormat setting when no timeFormat prop is passed', () => {
+ mockedStore.dispatch(updateSettings('Message_TimeFormat', 'MMM Do YYYY'));
+
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useTimeFormat());
+ return null;
+ };
+
+ render(
+
+
+
+
+
+ );
+ expect(spy).toHaveBeenLastCalledWith('MMM Do YYYY');
+
+ act(() => {
+ mockedStore.dispatch(updateSettings('Message_TimeFormat', 'h:mm a'));
+ });
+
+ expect(spy).toHaveBeenLastCalledWith('h:mm a');
+ });
+
+ it('propagates a reactive prop change without re-rendering an unrelated consumer', () => {
+ const timeFormatSpy = jest.fn();
+ 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(() => {
+ timeFormatSpy(useTimeFormat());
+ return null;
+ });
+ const AutoTranslateProbe = memo(() => {
+ autoTranslateSpy(useAutoTranslate());
+ return null;
+ });
+
+ const wrap = (autoTranslateRoom: boolean) => (
+
+
+
+
+
+
+ );
+
+ const { rerender } = render(wrap(false));
+ expect(autoTranslateSpy).toHaveBeenLastCalledWith({ autoTranslateRoom: false, autoTranslateLanguage: undefined });
+ const timeFormatCallsBefore = timeFormatSpy.mock.calls.length;
+
+ act(() => rerender(wrap(true)));
+
+ expect(autoTranslateSpy).toHaveBeenLastCalledWith({ autoTranslateRoom: true, autoTranslateLanguage: undefined });
+ expect(timeFormatSpy.mock.calls.length).toBe(timeFormatCallsBefore);
+ });
+
+ it('resyncs archived when a room gets archived mid-session', () => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useIsArchived());
+ return null;
+ };
+ const wrap = (archived: boolean) => (
+
+
+
+
+
+ );
+
+ const { rerender } = render(wrap(false));
+ expect(spy).toHaveBeenLastCalledWith(false);
+
+ act(() => rerender(wrap(true)));
+ expect(spy).toHaveBeenLastCalledWith(true);
+ });
+
+ describe('frozen handler guard (dev)', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ it('warns once when a frozen handler identity changes after mount', () => {
+ const wrap = (navToRoomInfo: () => void) => (
+
+
+ probe
+
+
+ );
+
+ const { rerender } = render(wrap(() => {}));
+ expect(warnSpy).not.toHaveBeenCalled();
+
+ act(() => rerender(wrap(() => {})));
+
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('navToRoomInfo'));
+ });
+
+ it('does not warn when the same handler reference is passed across re-renders', () => {
+ const navToRoomInfo = () => {};
+ const wrap = () => (
+
+
+ probe
+
+
+ );
+
+ const { rerender } = render(wrap());
+ act(() => rerender(wrap()));
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/containers/message/stores/__tests__/MessageStore.test.tsx b/app/containers/message/stores/__tests__/MessageStore.test.tsx
new file mode 100644
index 00000000000..fd8a8a08dee
--- /dev/null
+++ b/app/containers/message/stores/__tests__/MessageStore.test.tsx
@@ -0,0 +1,1092 @@
+import { act, fireEvent, render, renderHook } from '@testing-library/react-native';
+import { type ReactNode } from 'react';
+import { Keyboard, Text } from 'react-native';
+import { Provider } from 'react-redux';
+
+import { type IAttachment, type TAnyMessageModel } from '../../../../definitions';
+import { mockedStore } from '../../../../reducers/mockedStore';
+import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../../lib/constants/keys';
+import { messagesStatus } from '../../../../lib/constants/messagesStatus';
+import openLink from '../../../../lib/methods/helpers/openLink';
+import { MessageRoomProvider, type MessageRoomState } from '../MessageRoomStore';
+import {
+ MessageProvider,
+ useAvatar,
+ useBlocks,
+ useContentData,
+ useDiscussion,
+ useIsEdited,
+ useIsEncrypted,
+ useIsInfoMessage,
+ useMessageAuthor,
+ useMessageField,
+ useMessageGrouping,
+ useMessageIgnored,
+ useMessageHeaderMeta,
+ useMessageItem,
+ useMessageLongPress,
+ useMessagePress,
+ useMessageStatus,
+ useMessageText,
+ useMessageTouchable,
+ useOnLinkPress,
+ useReactions,
+ useRepliedThreadData,
+ useRevealIgnored,
+ useThreadData,
+ useThreadPosition,
+ useTranslateLanguage,
+ useUrls
+} from '../MessageStore';
+
+jest.mock('../../../../lib/methods/helpers/openLink', () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+const mockOpenLink = openLink as jest.Mock;
+
+type Subscriber = () => void;
+
+type FakeModel = TAnyMessageModel & { _emit: () => void; experimentalSubscribe: (cb: Subscriber) => () => void };
+
+const buildFakeModel = (overrides: Partial = {}): FakeModel => {
+ const subscribers: Subscriber[] = [];
+
+ const model = {
+ id: 'msg-1',
+ msg: 'Hello world',
+ t: undefined,
+ u: { _id: 'u1', username: 'alice' },
+ alias: undefined,
+ role: undefined,
+ avatar: undefined,
+ emoji: undefined,
+ attachments: [{ image_url: 'https://example.com/img.png' }],
+ urls: [],
+ reactions: [{ emoji: '👍', usernames: ['alice'] }],
+ blocks: null,
+ dcount: undefined,
+ dlm: undefined,
+ tmid: undefined,
+ tcount: undefined,
+ tlm: undefined,
+ tmsg: undefined,
+ mentions: [],
+ channels: [],
+ comment: undefined,
+ pinned: false,
+ md: [{ type: 'PARAGRAPH' }],
+ ...overrides,
+ experimentalSubscribe(cb: Subscriber) {
+ subscribers.push(cb);
+ return () => {
+ const idx = subscribers.indexOf(cb);
+ if (idx !== -1) subscribers.splice(idx, 1);
+ };
+ },
+ _emit() {
+ subscribers.forEach(cb => cb());
+ }
+ } as unknown as FakeModel;
+
+ return model;
+};
+
+// One probe per `probes` entry; mirrors how each leaf calls a single domain hook.
+const renderMessage = (item: TAnyMessageModel, probes: Record unknown>) => {
+ const probeSpies: Record = {};
+
+ const probeElements = Object.entries(probes).map(([name, useHook]) => {
+ const spy = jest.fn();
+ probeSpies[name] = spy;
+ const Probe = () => {
+ spy(useHook());
+ return null;
+ };
+ return ;
+ });
+
+ render(
+
+ <>{probeElements}>
+
+ );
+
+ const renderCount = (name: string) => probeSpies[name].mock.calls.length;
+ const latestValue = (name: string) => {
+ const { calls } = probeSpies[name].mock;
+ return calls[calls.length - 1]?.[0];
+ };
+
+ return { renderCount, latestValue };
+};
+
+const MsgValueProbe = ({ item, spy }: { item: TAnyMessageModel; spy: jest.Mock }) => (
+
+
+
+);
+
+const MsgValueReporter = ({ spy }: { spy: jest.Mock }) => {
+ spy(useMessageField(m => m.msg));
+ return null;
+};
+
+// Derived hooks read both the per-row store/item/previousItem and the room state in
+// MessageRoomProvider, so the probe needs both providers mounted.
+const renderDerived = (
+ item: TAnyMessageModel,
+ useHook: () => unknown,
+ { previousItem, config }: { previousItem?: TAnyMessageModel; config?: Partial } = {}
+) => {
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useHook());
+ return null;
+ };
+ render(
+
+
+
+
+
+
+
+ );
+ const latest = () => {
+ const { calls } = spy.mock;
+ return calls[calls.length - 1]?.[0];
+ };
+ return { latest, spy };
+};
+
+const wrapWithProviders =
+ (item: TAnyMessageModel, config?: Partial) =>
+ ({ children }: { children: ReactNode }) =>
+ (
+
+
+ {children}
+
+
+ );
+
+// Like wrapWithProviders, but also forwards the row-level MessageProvider props (onPress,
+// onLongPress, isIgnored) needed by the touch/press/long-press hooks.
+const renderMessageHook = (
+ useHook: () => T,
+ item: TAnyMessageModel,
+ options: {
+ isIgnored?: boolean;
+ onPress?: () => void;
+ onLongPress?: (msg: TAnyMessageModel) => void;
+ config?: Partial;
+ } = {}
+) =>
+ renderHook(() => useHook(), {
+ wrapper: ({ children }: { children: ReactNode }) => (
+
+
+
+ {children}
+
+
+
+ )
+ });
+
+describe('MessageStore', () => {
+ describe('field hooks', () => {
+ it('return the current field values', () => {
+ const model = buildFakeModel();
+ const { latestValue } = renderMessage(model, { reactions: useReactions, urls: useUrls, author: useMessageAuthor });
+
+ expect(latestValue('reactions')).toBe(model.reactions);
+ expect(latestValue('urls')).toBe(model.urls);
+ expect(latestValue('author')).toEqual({ u: model.u, alias: model.alias, role: model.role });
+ });
+
+ it('return the new value after a model mutation followed by an experimentalSubscribe emit', () => {
+ const model = buildFakeModel();
+ const { latestValue } = renderMessage(model, { urls: useUrls });
+
+ act(() => {
+ (model as any).urls = [{ url: 'https://rocket.chat' }];
+ model._emit();
+ });
+
+ expect(latestValue('urls')).toEqual([{ url: 'https://rocket.chat' }]);
+ });
+
+ it('does not throw and returns current values for a plain object without experimentalSubscribe', () => {
+ const plainItem = {
+ id: 'rest-1',
+ msg: 'Plain REST message',
+ reactions: [{ emoji: '👍', usernames: ['alice'] }]
+ } as unknown as TAnyMessageModel;
+
+ expect(() => renderMessage(plainItem, { reactions: useReactions })).not.toThrow();
+ });
+
+ it('reflects an item identity change', () => {
+ const modelA = buildFakeModel({ id: 'msg-a', msg: 'Model A' });
+ const modelB = buildFakeModel({ id: 'msg-b', msg: 'Model B' });
+ const spy = jest.fn();
+
+ const { rerender } = render();
+ expect(spy).toHaveBeenLastCalledWith('Model A');
+
+ act(() => rerender());
+
+ expect(spy).toHaveBeenLastCalledWith('Model B');
+ });
+
+ it('useMessageItem returns a stable ref across a tick bump (in-place mutation)', () => {
+ const model = buildFakeModel();
+ const { renderCount, latestValue } = renderMessage(model, { item: useMessageItem });
+
+ expect(latestValue('item')).toBe(model);
+ const baseline = renderCount('item');
+
+ act(() => {
+ (model as any).urls = [{ url: 'x' }];
+ model._emit();
+ });
+
+ expect(renderCount('item')).toBe(baseline);
+ expect(latestValue('item')).toBe(model);
+ });
+ });
+
+ describe('domain hooks', () => {
+ it('useBlocks selects blocks and id', () => {
+ const model = buildFakeModel({ blocks: [{ type: 'divider', appId: 'app-1' }] });
+ const { latestValue } = renderMessage(model, { blocks: useBlocks });
+ expect(latestValue('blocks')).toEqual({ blocks: model.blocks, id: model.id });
+ });
+
+ it('useDiscussion selects dcount and dlm', () => {
+ const model = buildFakeModel({ msg: 'discuss', dcount: 2, dlm: new Date('2024-01-01') });
+ const { latestValue } = renderMessage(model, { discussion: useDiscussion });
+ expect(latestValue('discussion')).toEqual({ dcount: model.dcount, dlm: model.dlm });
+ });
+
+ it('useThreadData selects tcount, tlm, tmid and id', () => {
+ const model = buildFakeModel({ tmid: 't1', tcount: 3, tlm: new Date('2024-01-02') });
+ const { latestValue } = renderMessage(model, { thread: useThreadData });
+ expect(latestValue('thread')).toEqual({
+ tcount: model.tcount,
+ tlm: model.tlm,
+ tmid: model.tmid,
+ id: model.id
+ });
+ });
+
+ it('useRepliedThreadData selects tmid, tmsg and id', () => {
+ const model = buildFakeModel({ tmid: 't1', tmsg: 'replied' });
+ const { latestValue } = renderMessage(model, { repliedThread: useRepliedThreadData });
+ expect(latestValue('repliedThread')).toEqual({ tmid: model.tmid, tmsg: model.tmsg, id: model.id });
+ });
+
+ it('useAvatar selects avatar and emoji', () => {
+ const model = buildFakeModel({ avatar: 'avatar.png', emoji: ':smile:' });
+ const { latestValue } = renderMessage(model, { avatar: useAvatar });
+ expect(latestValue('avatar')).toEqual({ avatar: model.avatar, emoji: model.emoji });
+ });
+
+ it('useContentData selects the raw content fields', () => {
+ const model = buildFakeModel();
+ const { latestValue } = renderMessage(model, { content: useContentData });
+ expect(latestValue('content')).toEqual({
+ md: model.md,
+ mentions: model.mentions,
+ channels: model.channels,
+ comment: model.comment,
+ attachments: model.attachments,
+ t: model.t
+ });
+ });
+ });
+
+ describe('granularity', () => {
+ it('re-renders only the consumer of the field that changed', () => {
+ const model = buildFakeModel();
+ const { renderCount } = renderMessage(model, { reactions: useReactions, urls: useUrls });
+
+ const reactionsBaseline = renderCount('reactions');
+ const urlsBaseline = renderCount('urls');
+
+ act(() => {
+ (model as any).urls = [{ url: 'https://rocket.chat' }];
+ model._emit();
+ });
+
+ expect(renderCount('urls')).toBeGreaterThan(urlsBaseline);
+ expect(renderCount('reactions')).toBe(reactionsBaseline);
+ });
+
+ it('bails a multi-field (useShallow) consumer when an unrelated field changes', () => {
+ const model = buildFakeModel();
+ const { renderCount } = renderMessage(model, { author: useMessageAuthor, reactions: useReactions });
+
+ const authorBaseline = renderCount('author');
+ const reactionsBaseline = renderCount('reactions');
+
+ act(() => {
+ (model as any).reactions = [{ emoji: '🎉', usernames: ['bob'] }];
+ model._emit();
+ });
+
+ expect(renderCount('reactions')).toBeGreaterThan(reactionsBaseline);
+ expect(renderCount('author')).toBe(authorBaseline);
+ });
+ });
+
+ describe('per-instance isolation', () => {
+ it('keeps state independent across provider instances', () => {
+ const modelA = buildFakeModel({ id: 'msg-a', msg: 'Model A' });
+ const modelB = buildFakeModel({ id: 'msg-b', msg: 'Model B' });
+
+ const spyA = jest.fn();
+ const spyB = jest.fn();
+
+ render(
+ <>
+
+
+ >
+ );
+
+ expect(spyA).toHaveBeenLastCalledWith('Model A');
+ expect(spyB).toHaveBeenLastCalledWith('Model B');
+
+ act(() => {
+ (modelA as any).msg = 'Model A updated';
+ modelA._emit();
+ });
+
+ expect(spyA).toHaveBeenLastCalledWith('Model A updated');
+ expect(spyB).toHaveBeenLastCalledWith('Model B');
+ });
+ });
+
+ describe('derived hooks', () => {
+ it('useMessageStatus returns hasError false and isTemp false for a normal model', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useMessageStatus);
+ expect(latest()).toEqual({ hasError: false, isTemp: false });
+ });
+
+ it('useMessageStatus returns hasError true and isTemp true for an ERROR status model', () => {
+ const model = buildFakeModel({ status: messagesStatus.ERROR });
+ const { latest } = renderDerived(model, useMessageStatus);
+ expect(latest()).toEqual({ hasError: true, isTemp: true });
+ });
+
+ it('useMessageStatus returns hasError false and isTemp true for a TEMP status model', () => {
+ const model = buildFakeModel({ status: messagesStatus.TEMP });
+ const { latest } = renderDerived(model, useMessageStatus);
+ expect(latest()).toEqual({ hasError: false, isTemp: true });
+ });
+
+ it('useIsEncrypted returns true for a pending e2e message', () => {
+ const model = buildFakeModel({ t: E2E_MESSAGE_TYPE, e2e: 'pending' });
+ const { latest } = renderDerived(model, useIsEncrypted);
+ expect(latest()).toBe(true);
+ });
+
+ it('useIsEncrypted returns false for a done e2e message', () => {
+ const model = buildFakeModel({ t: E2E_MESSAGE_TYPE, e2e: E2E_STATUS.DONE });
+ const { latest } = renderDerived(model, useIsEncrypted);
+ expect(latest()).toBe(false);
+ });
+
+ it('useIsEncrypted returns false for a plain text message', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useIsEncrypted);
+ expect(latest()).toBe(false);
+ });
+
+ it('useIsInfoMessage returns false for a discussion-created message', () => {
+ const model = buildFakeModel({ t: 'discussion-created' });
+ const { latest } = renderDerived(model, useIsInfoMessage);
+ expect(latest()).toBe(false);
+ });
+
+ it('useIsInfoMessage returns true for an info message', () => {
+ const model = buildFakeModel({ t: 'room_changed_topic' });
+ const { latest } = renderDerived(model, useIsInfoMessage);
+ expect(latest()).toBe(true);
+ });
+
+ it('useIsInfoMessage returns false for a default text message', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useIsInfoMessage);
+ expect(latest()).toBe(false);
+ });
+
+ it('useIsEdited returns false for a default model', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useIsEdited);
+ expect(latest()).toBe(false);
+ });
+
+ it('useIsEdited returns true for a model with editedBy', () => {
+ const model = buildFakeModel({ editedBy: { _id: 'u2', username: 'bob' } });
+ const { latest } = renderDerived(model, useIsEdited);
+ expect(latest()).toBe(true);
+ });
+
+ it('useThreadPosition returns false/false when isThreadRoom is true', () => {
+ const model = buildFakeModel({ tmid: 't1' });
+ const { latest } = renderDerived(model, useThreadPosition, { config: { isThreadRoom: true } });
+ expect(latest()).toEqual({ isThreadReply: false, isThreadSequential: false });
+ });
+
+ it('useThreadPosition returns isThreadReply true when the message replies to a different thread than prev', () => {
+ const model = buildFakeModel({ tmid: 't1' });
+ const { latest } = renderDerived(model, useThreadPosition, { previousItem: buildFakeModel({ id: 'p0', tmid: 't0' }) });
+ expect(latest()).toEqual({ isThreadReply: true, isThreadSequential: true });
+ });
+
+ it('useThreadPosition returns isThreadReply false when prev is in the same thread', () => {
+ const model = buildFakeModel({ tmid: 't1' });
+ const { latest } = renderDerived(model, useThreadPosition, { previousItem: buildFakeModel({ id: 'p0', tmid: 't1' }) });
+ expect(latest()).toEqual({ isThreadReply: false, isThreadSequential: true });
+ });
+
+ it('useMessageGrouping returns true (header) when there is no prev', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useMessageGrouping);
+ expect(latest()).toBe(true);
+ });
+
+ it('useMessageGrouping returns true (header) when prev has an ERROR status', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem: buildFakeModel({ id: 'p0', status: messagesStatus.ERROR })
+ });
+ expect(latest()).toBe(true);
+ });
+
+ it('useMessageGrouping returns false when prev matches author/day/period/thread', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const model = buildFakeModel({ ts, tmid: 't1' });
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem: buildFakeModel({ id: 'p0', ts: prevTs, tmid: 't1' }),
+ config: { broadcast: false, Message_GroupingPeriod: 300 }
+ });
+ expect(latest()).toBe(false);
+ });
+
+ it('useMessageGrouping returns true (header) when the room is broadcast, even if prev would otherwise group', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const model = buildFakeModel({ ts, tmid: 't1' });
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem: buildFakeModel({ id: 'p0', ts: prevTs, tmid: 't1' }),
+ config: { broadcast: true, Message_GroupingPeriod: 300 }
+ });
+ expect(latest()).toBe(true);
+ });
+
+ it('useMessageGrouping returns true (header) when groupable is false, even if prev would otherwise group', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const model = buildFakeModel({ ts, tmid: 't1', groupable: false });
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem: buildFakeModel({ id: 'p0', ts: prevTs, tmid: 't1' }),
+ config: { broadcast: false, Message_GroupingPeriod: 300 }
+ });
+ expect(latest()).toBe(true);
+ });
+
+ it('useMessageGrouping returns true (header) for a removed message, even if prev would otherwise group', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const model = buildFakeModel({ ts, tmid: 't1', t: 'rm' });
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem: buildFakeModel({ id: 'p0', ts: prevTs, tmid: 't1' }),
+ config: { broadcast: false, Message_GroupingPeriod: 300 }
+ });
+ expect(latest()).toBe(true);
+ });
+
+ it('flips to header when the previous message transitions to ERROR in place, without re-passing props', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const model = buildFakeModel({ ts, tmid: 't1' });
+ const previousItem = buildFakeModel({ id: 'p0', ts: prevTs, tmid: 't1' });
+ const { latest } = renderDerived(model, useMessageGrouping, {
+ previousItem,
+ config: { broadcast: false, Message_GroupingPeriod: 300 }
+ });
+
+ expect(latest()).toBe(false);
+
+ act(() => {
+ (previousItem as any).status = messagesStatus.ERROR;
+ previousItem._emit();
+ });
+
+ expect(latest()).toBe(true);
+ });
+
+ it('unsubscribes the old previous model and subscribes the new one on a temp→server swap', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const prevTs = new Date('2024-01-01T09:59:50Z');
+ const item = buildFakeModel({ id: 'msg-1', ts, tmid: 't1' });
+ const previousTemp = buildFakeModel({ id: 'p-temp', ts: prevTs, tmid: 't1' });
+ const previousServer = buildFakeModel({ id: 'p-server', ts: prevTs, tmid: 't1' });
+
+ const unsubscribeSpy = jest.fn();
+ const realSubscribe = previousTemp.experimentalSubscribe.bind(previousTemp);
+ previousTemp.experimentalSubscribe = ((cb: Subscriber) => {
+ const realUnsub = realSubscribe(cb);
+ return () => {
+ unsubscribeSpy();
+ realUnsub();
+ };
+ }) as FakeModel['experimentalSubscribe'];
+
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageGrouping());
+ return null;
+ };
+ const latest = () => {
+ const { calls } = spy.mock;
+ return calls[calls.length - 1]?.[0];
+ };
+ const wrap = (previousItem: TAnyMessageModel) => (
+
+
+
+
+
+
+
+ );
+
+ const { rerender } = render(wrap(previousTemp));
+ expect(latest()).toBe(false);
+ expect(unsubscribeSpy).not.toHaveBeenCalled();
+
+ act(() => rerender(wrap(previousServer)));
+ expect(unsubscribeSpy).toHaveBeenCalledTimes(1);
+ expect(latest()).toBe(false);
+
+ // An in-place change on the NEW previous model re-derives grouping, proving it is subscribed.
+ act(() => {
+ (previousServer as any).status = messagesStatus.ERROR;
+ previousServer._emit();
+ });
+ expect(latest()).toBe(true);
+ });
+
+ it('useMessageText returns the raw message when there is no auto-translation', () => {
+ const model = buildFakeModel();
+ const { latest } = renderDerived(model, useMessageText);
+ expect(latest()).toEqual({ messageText: model.msg, isTranslated: false });
+ });
+
+ it('useMessageText returns the translated message when auto-translate conditions are met', () => {
+ const model = buildFakeModel({
+ autoTranslate: true,
+ translations: [{ _id: 't1', language: 'pt-BR', value: 'Olá mundo' }],
+ u: { _id: 'u2', username: 'bob' }
+ });
+ const { latest } = renderDerived(model, useMessageText, {
+ config: { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR', user: { username: 'alice' } }
+ });
+ expect(latest()).toEqual({ messageText: 'Olá mundo', isTranslated: true });
+ });
+
+ it('useMessageHeaderMeta returns ts, unread, pinned and t', () => {
+ const ts = new Date('2024-01-01T10:00:00Z');
+ const model = buildFakeModel({ ts, unread: true, pinned: true, t: 'room_changed_topic' });
+ const { latest } = renderDerived(model, useMessageHeaderMeta);
+ expect(latest()).toEqual({ ts: model.ts, unread: model.unread, pinned: model.pinned, t: model.t });
+ });
+ });
+
+ describe('outside a MessageProvider', () => {
+ let consoleErrorSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('useMessageItem throws', () => {
+ const Probe = () => {
+ useMessageItem();
+ return null;
+ };
+ expect(() => render()).toThrow('Message hooks must be used within a MessageProvider');
+ });
+
+ it('useMessageField throws', () => {
+ const Probe = () => {
+ useReactions();
+ return null;
+ };
+ expect(() => render()).toThrow('Message hooks must be used within a MessageProvider');
+ });
+ });
+
+ describe('ignored messages', () => {
+ it('useMessageIgnored reflects the isIgnored seed', () => {
+ const model = buildFakeModel();
+ const ignoredSpy = jest.fn();
+ const notIgnoredSpy = jest.fn();
+ const IgnoredProbe = () => {
+ ignoredSpy(useMessageIgnored());
+ return null;
+ };
+ const NotIgnoredProbe = () => {
+ notIgnoredSpy(useMessageIgnored());
+ return null;
+ };
+
+ render(
+
+
+
+ );
+ render(
+
+
+
+ );
+
+ expect(ignoredSpy).toHaveBeenLastCalledWith(true);
+ expect(notIgnoredSpy).toHaveBeenLastCalledWith(false);
+ });
+
+ it('revealIgnored flips useMessageIgnored to false and stays revealed across re-renders', () => {
+ const model = buildFakeModel();
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageIgnored());
+ const reveal = useRevealIgnored();
+ return ;
+ };
+ const wrap = () => (
+
+
+
+ );
+
+ const { rerender, getByTestId } = render(wrap());
+ expect(spy).toHaveBeenLastCalledWith(true);
+
+ fireEvent.press(getByTestId('reveal'));
+ expect(spy).toHaveBeenLastCalledWith(false);
+
+ // The reset effect is keyed on isIgnored; a re-render with the same seed does not re-run it, so the reveal sticks.
+ act(() => rerender(wrap()));
+ expect(spy).toHaveBeenLastCalledWith(false);
+ });
+
+ it('re-hides after the ignore state transitions away and back', () => {
+ const model = buildFakeModel();
+ const spy = jest.fn();
+ const Probe = () => {
+ spy(useMessageIgnored());
+ const reveal = useRevealIgnored();
+ return ;
+ };
+ const wrap = (ignored: boolean) => (
+
+
+
+ );
+
+ const { rerender, getByTestId } = render(wrap(true));
+ expect(spy).toHaveBeenLastCalledWith(true);
+
+ fireEvent.press(getByTestId('reveal'));
+ expect(spy).toHaveBeenLastCalledWith(false);
+
+ act(() => rerender(wrap(false)));
+ expect(spy).toHaveBeenLastCalledWith(false);
+
+ act(() => rerender(wrap(true)));
+ expect(spy).toHaveBeenLastCalledWith(true);
+ });
+ });
+
+ describe('useOnLinkPress', () => {
+ beforeEach(() => {
+ mockOpenLink.mockClear();
+ });
+
+ it('jumps to the message when the link matches an attachment message_link', () => {
+ const jumpToMessage = jest.fn();
+ const link = 'https://example.com/msg-link';
+ const model = buildFakeModel({ attachments: [{ message_link: link } as IAttachment] });
+ const { result } = renderHook(() => useOnLinkPress(), { wrapper: wrapWithProviders(model, { jumpToMessage }) });
+
+ result.current(link);
+
+ expect(jumpToMessage).toHaveBeenCalledWith(link);
+ expect(mockOpenLink).not.toHaveBeenCalled();
+ });
+
+ it('opens the link when it does not match any attachment message_link', () => {
+ const jumpToMessage = jest.fn();
+ const link = 'https://example.com/other';
+ const model = buildFakeModel({ attachments: [{ message_link: 'https://example.com/msg-link' } as IAttachment] });
+ const { result } = renderHook(() => useOnLinkPress(), { wrapper: wrapWithProviders(model, { jumpToMessage }) });
+
+ result.current(link);
+
+ expect(jumpToMessage).not.toHaveBeenCalled();
+ expect(mockOpenLink).toHaveBeenCalledWith(link, 'light');
+ });
+
+ it('does not jump when attachments is undefined', () => {
+ const jumpToMessage = jest.fn();
+ const link = 'https://example.com/msg-link';
+ const model = buildFakeModel({ attachments: undefined });
+ const { result } = renderHook(() => useOnLinkPress(), { wrapper: wrapWithProviders(model, { jumpToMessage }) });
+
+ result.current(link);
+
+ expect(jumpToMessage).not.toHaveBeenCalled();
+ expect(mockOpenLink).toHaveBeenCalledWith(link, 'light');
+ });
+ });
+
+ describe('useMessageTouchable', () => {
+ it('is fully tappable and long-pressable for a plain text message', () => {
+ const model = buildFakeModel();
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: true, longPressable: true, revealsIgnored: false });
+ });
+
+ it('is untouchable for an info message', () => {
+ const model = buildFakeModel({ t: 'room_changed_topic' });
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+
+ it('is untouchable when the message has an error status', () => {
+ const model = buildFakeModel({ status: messagesStatus.ERROR });
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+
+ it('is untouchable when the room is archived', () => {
+ const model = buildFakeModel();
+ const { result } = renderMessageHook(useMessageTouchable, model, { config: { archived: true } });
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+
+ // Post-refactor: tappable now also excludes isTemp, so a still-sending message loses both
+ // tap and long-press here — the old long-press guard only checked isInfo/hasError/isEncrypted/archived.
+ it('is untouchable while the message is temp (sending)', () => {
+ const model = buildFakeModel({ status: messagesStatus.TEMP });
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+
+ // Post-refactor: tappable now also excludes jitsi_call_started, which the old long-press guard did not.
+ it('is untouchable for a jitsi_call_started message', () => {
+ const model = buildFakeModel({ t: 'jitsi_call_started' });
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+
+ it('is tappable but not long-pressable for a pending encrypted message', () => {
+ const model = buildFakeModel({ t: E2E_MESSAGE_TYPE, e2e: 'pending' });
+ const { result } = renderMessageHook(useMessageTouchable, model);
+ expect(result.current).toEqual({ tappable: true, longPressable: false, revealsIgnored: false });
+ });
+
+ it('reveals ignored on an otherwise-tappable ignored message', () => {
+ const model = buildFakeModel();
+ const { result } = renderMessageHook(useMessageTouchable, model, { isIgnored: true });
+ expect(result.current).toEqual({ tappable: true, longPressable: true, revealsIgnored: true });
+ });
+
+ it('does not reveal ignored when the message is not tappable', () => {
+ const model = buildFakeModel();
+ const { result } = renderMessageHook(useMessageTouchable, model, { isIgnored: true, config: { archived: true } });
+ expect(result.current).toEqual({ tappable: false, longPressable: false, revealsIgnored: false });
+ });
+ });
+
+ describe('useMessageLongPress', () => {
+ it('fires onLongPress with the item when longPressable', () => {
+ const model = buildFakeModel();
+ const onLongPress = jest.fn();
+ const { result } = renderMessageHook(useMessageLongPress, model, { onLongPress });
+
+ act(() => result.current());
+
+ expect(onLongPress).toHaveBeenCalledWith(model);
+ });
+
+ it('does not fire onLongPress for an info message', () => {
+ const model = buildFakeModel({ t: 'room_changed_topic' });
+ const onLongPress = jest.fn();
+ const { result } = renderMessageHook(useMessageLongPress, model, { onLongPress });
+
+ act(() => result.current());
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+
+ it('does not fire onLongPress for an encrypted message', () => {
+ const model = buildFakeModel({ t: E2E_MESSAGE_TYPE, e2e: 'pending' });
+ const onLongPress = jest.fn();
+ const { result } = renderMessageHook(useMessageLongPress, model, { onLongPress });
+
+ act(() => result.current());
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+
+ // See useMessageTouchable above: this suppression is new vs the pre-refactor guard.
+ it('does not fire onLongPress on a temp (sending) message', () => {
+ const model = buildFakeModel({ status: messagesStatus.TEMP });
+ const onLongPress = jest.fn();
+ const { result } = renderMessageHook(useMessageLongPress, model, { onLongPress });
+
+ act(() => result.current());
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+
+ it('does not fire onLongPress on a jitsi_call_started message', () => {
+ const model = buildFakeModel({ t: 'jitsi_call_started' });
+ const onLongPress = jest.fn();
+ const { result } = renderMessageHook(useMessageLongPress, model, { onLongPress });
+
+ act(() => result.current());
+
+ expect(onLongPress).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('useMessagePress', () => {
+ let dismissSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ dismissSpy = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ dismissSpy.mockRestore();
+ jest.useRealTimers();
+ });
+
+ 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 } });
+
+ act(() => result.current());
+
+ expect(dismissSpy).not.toHaveBeenCalled();
+ expect(onThreadPress).not.toHaveBeenCalled();
+ });
+
+ it('calls the row onPress handler and skips keyboard dismiss and thread/discussion routing', () => {
+ const model = buildFakeModel({ tmid: 't1', dlm: new Date(), drid: 'drid-1' });
+ const onPress = jest.fn();
+ const onThreadPress = jest.fn();
+ const onDiscussionPress = jest.fn();
+ const { result } = renderMessageHook(useMessagePress, model, { onPress, config: { onThreadPress, onDiscussionPress } });
+
+ act(() => result.current());
+
+ expect(onPress).toHaveBeenCalledTimes(1);
+ expect(dismissSpy).not.toHaveBeenCalled();
+ expect(onThreadPress).not.toHaveBeenCalled();
+ expect(onDiscussionPress).not.toHaveBeenCalled();
+ });
+
+ 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 } });
+
+ act(() => result.current());
+
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+ expect(onThreadPress).toHaveBeenCalledWith(model);
+ });
+
+ 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 } });
+
+ act(() => result.current());
+
+ expect(onThreadPress).toHaveBeenCalledWith(model);
+ });
+
+ 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 } });
+
+ act(() => result.current());
+
+ expect(onThreadPress).not.toHaveBeenCalled();
+ });
+
+ 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 } });
+
+ act(() => result.current());
+
+ expect(onDiscussionPress).toHaveBeenCalledWith('drid-1');
+ });
+
+ 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 } });
+
+ act(() => result.current());
+ expect(onThreadPress).toHaveBeenCalledTimes(1);
+
+ act(() => result.current());
+ expect(onThreadPress).toHaveBeenCalledTimes(1);
+
+ act(() => jest.advanceTimersByTime(300));
+
+ act(() => result.current());
+ expect(onThreadPress).toHaveBeenCalledTimes(2);
+ });
+
+ it('routes the debounced press through closeEmojiAndAction instead of running it directly', () => {
+ const model = buildFakeModel({ tmid: 't1' });
+ const onThreadPress = jest.fn();
+ const closeEmojiAndAction = jest.fn();
+ const { result } = renderMessageHook(useMessagePress, model, { config: { onThreadPress, closeEmojiAndAction } });
+
+ act(() => result.current());
+
+ expect(closeEmojiAndAction).toHaveBeenCalledWith(expect.any(Function));
+ expect(dismissSpy).not.toHaveBeenCalled();
+ expect(onThreadPress).not.toHaveBeenCalled();
+ });
+
+ it('runs the gated press once closeEmojiAndAction invokes the handler it was given', () => {
+ 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 } });
+
+ act(() => result.current());
+
+ expect(dismissSpy).toHaveBeenCalledTimes(1);
+ expect(onThreadPress).toHaveBeenCalledWith(model);
+ });
+ });
+
+ describe('translation boundary on item.autoTranslate', () => {
+ const translationConfig = { autoTranslateRoom: true, autoTranslateLanguage: 'pt-BR', user: { username: 'alice' } };
+
+ it('useTranslateLanguage returns undefined when autoTranslate is undefined', () => {
+ const model = buildFakeModel({ autoTranslate: undefined, u: { _id: 'u2', username: 'bob' } });
+ const { latest } = renderDerived(model, useTranslateLanguage, { config: translationConfig });
+ expect(latest()).toBeUndefined();
+ });
+
+ it('useTranslateLanguage returns the language when autoTranslate is true', () => {
+ const model = buildFakeModel({ autoTranslate: true, u: { _id: 'u2', username: 'bob' } });
+ const { latest } = renderDerived(model, useTranslateLanguage, { config: translationConfig });
+ expect(latest()).toBe('pt-BR');
+ });
+
+ it('useTranslateLanguage returns undefined when autoTranslate is false', () => {
+ const model = buildFakeModel({ autoTranslate: false, u: { _id: 'u2', username: 'bob' } });
+ const { latest } = renderDerived(model, useTranslateLanguage, { config: translationConfig });
+ expect(latest()).toBeUndefined();
+ });
+
+ it('useMessageText does not translate when autoTranslate is undefined, even in an auto-translating room', () => {
+ const model = buildFakeModel({
+ autoTranslate: undefined,
+ translations: [{ _id: 't1', language: 'pt-BR', value: 'Olá mundo' }],
+ u: { _id: 'u2', username: 'bob' }
+ });
+ const { latest } = renderDerived(model, useMessageText, { config: translationConfig });
+ expect(latest()).toEqual({ messageText: model.msg, isTranslated: false });
+ });
+
+ it('useMessageText translates when autoTranslate is true', () => {
+ const model = buildFakeModel({
+ autoTranslate: true,
+ translations: [{ _id: 't1', language: 'pt-BR', value: 'Olá mundo' }],
+ u: { _id: 'u2', username: 'bob' }
+ });
+ const { latest } = renderDerived(model, useMessageText, { config: translationConfig });
+ expect(latest()).toEqual({ messageText: 'Olá mundo', isTranslated: true });
+ });
+
+ it('useMessageText does not translate when autoTranslate is false', () => {
+ const model = buildFakeModel({
+ autoTranslate: false,
+ translations: [{ _id: 't1', language: 'pt-BR', value: 'Olá mundo' }],
+ u: { _id: 'u2', username: 'bob' }
+ });
+ const { latest } = renderDerived(model, useMessageText, { config: translationConfig });
+ expect(latest()).toEqual({ messageText: model.msg, isTranslated: false });
+ });
+ });
+
+ describe('useMessageField misuse guard (dev)', () => {
+ let warnSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
+ });
+
+ afterEach(() => {
+ warnSpy.mockRestore();
+ });
+
+ // A selector that returns a fresh object on every call never lets React's useSyncExternalStore
+ // snapshot settle, so this misuse also trips React's own update-depth guard. The dev warning
+ // still fires (once) before that crash, which is what this test asserts.
+ it('warns when the selector returns a new object every call for unchanged data', () => {
+ const model = buildFakeModel();
+
+ expect(() => {
+ renderMessageHook(() => useMessageField(item => ({ id: item.id })), model);
+ }).toThrow('Maximum update depth exceeded');
+
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[MessageStore] useMessageField selector'));
+ });
+
+ it('does not warn for a primitive field selector', () => {
+ const model = buildFakeModel();
+ renderMessageHook(() => useMessageField(item => item.id), model);
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it('does not warn for a memoized object field selector (stable reference across calls)', () => {
+ const model = buildFakeModel();
+ renderMessageHook(() => useMessageField(item => item.reactions), model);
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/app/containers/message/utils.ts b/app/containers/message/utils.ts
index ae5e778311a..f01367d54de 100644
--- a/app/containers/message/utils.ts
+++ b/app/containers/message/utils.ts
@@ -1,6 +1,6 @@
/* eslint-disable complexity */
-import { type IAttachment } from '../../definitions';
-import { type MessageTypesValues, type TMessageModel } from '../../definitions/IMessage';
+import { type IAttachment, type IMessageTranslations } from '../../definitions';
+import { type MessageTypesValues, type TAnyMessageModel, type TMessageModel } from '../../definitions/IMessage';
import I18n from '../../i18n';
import { DISCUSSION } from './constants';
@@ -82,11 +82,11 @@ const messagesWithAuthorName: MessageTypesValues[] = [
export const messageHaveAuthorName = (type: MessageTypesValues): boolean => messagesWithAuthorName.includes(type);
type TInfoMessage = {
- type: MessageTypesValues;
- role: string;
- msg: string;
- author: { username: string };
- comment?: string;
+ type: TAnyMessageModel['t'];
+ role: TAnyMessageModel['role'];
+ msg: TAnyMessageModel['msg'];
+ author: TAnyMessageModel['u'];
+ comment?: TAnyMessageModel['comment'];
};
export const getInfoMessage = ({ type, role, msg, author, comment }: TInfoMessage): string => {
@@ -189,13 +189,16 @@ export const getInfoMessage = ({ type, role, msg, author, comment }: TInfoMessag
}
};
-export const getMessageTranslation = (message: TMessageModel, autoTranslateLanguage: string): string | null => {
+export const getMessageTranslation = (
+ message: TMessageModel | { translations?: TMessageModel['translations'] },
+ autoTranslateLanguage: string
+): string | null => {
if (!autoTranslateLanguage) {
return null;
}
const { translations } = message;
if (translations) {
- const translation = translations.find((trans: any) => trans.language === autoTranslateLanguage);
+ const translation = translations.find((trans: IMessageTranslations) => trans.language === autoTranslateLanguage);
return translation?.value || null;
}
return null;
diff --git a/app/definitions/IEmoji.ts b/app/definitions/IEmoji.ts
index 4b025979fe6..9adda58a5ab 100644
--- a/app/definitions/IEmoji.ts
+++ b/app/definitions/IEmoji.ts
@@ -22,6 +22,8 @@ export interface ICustomEmojis {
export type TGetCustomEmoji = (name: string) => ICustomEmoji | null;
+export const resolveCustomEmoji = (customEmojis: ICustomEmojis, name: string): ICustomEmoji | null => customEmojis[name] ?? null;
+
export type TFrequentlyUsedEmojiModel = IFrequentlyUsedEmoji & Model;
export interface ICustomEmojiModel {
diff --git a/app/definitions/IMessage.ts b/app/definitions/IMessage.ts
index 756228addec..c348b22d48e 100644
--- a/app/definitions/IMessage.ts
+++ b/app/definitions/IMessage.ts
@@ -1,5 +1,6 @@
import type Model from '@nozbe/watermelondb/Model';
import { type Root } from '@rocket.chat/message-parser';
+import { type LayoutBlock } from '@rocket.chat/ui-kit';
import { type MessageTypeLoad } from '../lib/constants/messageTypeLoad';
import { type IAttachment } from './IAttachment';
@@ -8,7 +9,13 @@ import { type TThreadMessageModel } from './IThreadMessage';
import { type TThreadModel } from './IThread';
import { type IUrl, type IUrlFromServer } from './IUrl';
-export type TMessageAction = 'quote' | 'edit' | 'react' | null;
+export type TMessageActionState =
+ | { kind: 'edit'; messageId: string }
+ | { kind: 'quote'; messageIds: string[] }
+ | { kind: 'react'; messageId: string }
+ | null;
+
+export type TMessageAction = NonNullable['kind'] | null;
export type MessageType =
| 'jitsi_call_started'
@@ -51,6 +58,11 @@ export interface IMessageTranslations {
export type E2EType = 'pending' | 'done';
+export interface IE2EEMentions {
+ e2eUserMentions: string[];
+ e2eChannelMentions: string[];
+}
+
export interface ILastMessage {
_id?: string;
rid?: string;
@@ -167,14 +179,14 @@ export interface IMessage extends IMessageFromServer {
autoTranslate?: boolean;
translations?: IMessageTranslations[];
tmsg?: string;
- blocks?: any;
+ blocks?: LayoutBlock[] | null;
e2e?: E2EType;
tshow?: boolean;
comment?: string;
subscription?: { id: string };
user?: string;
editedAt?: string | Date;
- e2eMentions?: any;
+ e2eMentions?: IE2EEMentions;
}
export type TMessageModel = IMessage &
diff --git a/app/definitions/IRoom.ts b/app/definitions/IRoom.ts
index e77221f13bc..2fd78ce4138 100644
--- a/app/definitions/IRoom.ts
+++ b/app/definitions/IRoom.ts
@@ -3,7 +3,7 @@ import type Model from '@nozbe/watermelondb/Model';
import { type IMessage } from './IMessage';
import { type IRocketChatRecord } from './IRocketChatRecord';
import { type IServedBy } from './IServedBy';
-import { type IVisitor, type SubscriptionType } from './ISubscription';
+import { type ISubscription, type IVisitor, type SubscriptionType } from './ISubscription';
import { type IUser, type TNotifications } from './IUser';
interface IRequestTranscript {
@@ -15,6 +15,15 @@ interface IRequestTranscript {
export type TUserWaitingForE2EKeys = { userId: string; ts: Date };
+export interface IRoomInfoParam {
+ room?: ISubscription;
+ member?: any;
+ rid: string;
+ t: SubscriptionType;
+ joined?: boolean;
+ itsMe?: boolean;
+}
+
export interface IRoom {
fname?: string;
_id: string;
diff --git a/app/lib/database/model/CustomEmoji.js b/app/lib/database/model/CustomEmoji.js
index c8fdedd057c..b3ce7362aec 100644
--- a/app/lib/database/model/CustomEmoji.js
+++ b/app/lib/database/model/CustomEmoji.js
@@ -10,7 +10,7 @@ export default class CustomEmoji extends Model {
@field('name') name;
- @json('aliases', sanitizer) aliases;
+ @json('aliases', sanitizer, { memo: true }) aliases;
@field('extension') extension;
diff --git a/app/lib/database/model/Message.js b/app/lib/database/model/Message.js
index 336f9c39351..d8693866ccd 100644
--- a/app/lib/database/model/Message.js
+++ b/app/lib/database/model/Message.js
@@ -18,13 +18,13 @@ export default class Message extends Model {
@date('ts') ts;
- @json('u', sanitizer) u;
+ @json('u', sanitizer, { memo: true }) u;
@relation('subscriptions', 'rid') subscription;
@field('alias') alias;
- @json('parse_urls', sanitizer) parseUrls;
+ @json('parse_urls', sanitizer, { memo: true }) parseUrls;
@field('groupable') groupable;
@@ -32,9 +32,9 @@ export default class Message extends Model {
@field('emoji') emoji;
- @json('attachments', sanitizer) attachments;
+ @json('attachments', sanitizer, { memo: true }) attachments;
- @json('urls', sanitizer) urls;
+ @json('urls', sanitizer, { memo: true }) urls;
@date('_updated_at') _updatedAt;
@@ -44,9 +44,9 @@ export default class Message extends Model {
@field('starred') starred;
- @json('edited_by', sanitizer) editedBy;
+ @json('edited_by', sanitizer, { memo: true }) editedBy;
- @json('reactions', sanitizer) reactions;
+ @json('reactions', sanitizer, { memo: true }) reactions;
@field('role') role;
@@ -62,29 +62,29 @@ export default class Message extends Model {
@date('tlm') tlm;
- @json('replies', sanitizer) replies;
+ @json('replies', sanitizer, { memo: true }) replies;
- @json('mentions', sanitizer) mentions;
+ @json('mentions', sanitizer, { memo: true }) mentions;
- @json('channels', sanitizer) channels;
+ @json('channels', sanitizer, { memo: true }) channels;
@field('unread') unread;
@field('auto_translate') autoTranslate;
- @json('translations', sanitizer) translations;
+ @json('translations', sanitizer, { memo: true }) translations;
@field('tmsg') tmsg;
- @json('blocks', sanitizer) blocks;
+ @json('blocks', sanitizer, { memo: true }) blocks;
@field('e2e') e2e;
@field('tshow') tshow;
- @json('md', sanitizer) md;
+ @json('md', sanitizer, { memo: true }) md;
- @json('content', sanitizer) content;
+ @json('content', sanitizer, { memo: true }) content;
@field('comment') comment;
diff --git a/app/lib/database/model/Permission.js b/app/lib/database/model/Permission.js
index c397f079a35..d1ebb7b9020 100644
--- a/app/lib/database/model/Permission.js
+++ b/app/lib/database/model/Permission.js
@@ -8,7 +8,7 @@ export const PERMISSIONS_TABLE = 'permissions';
export default class Permission extends Model {
static table = PERMISSIONS_TABLE;
- @json('roles', sanitizer) roles;
+ @json('roles', sanitizer, { memo: true }) roles;
@date('_updated_at') _updatedAt;
}
diff --git a/app/lib/database/model/Room.js b/app/lib/database/model/Room.js
index e2a1127bf56..2dfadbd1c7b 100644
--- a/app/lib/database/model/Room.js
+++ b/app/lib/database/model/Room.js
@@ -8,7 +8,7 @@ export const ROOMS_TABLE = 'rooms';
export default class Room extends Model {
static table = ROOMS_TABLE;
- @json('custom_fields', sanitizer) customFields;
+ @json('custom_fields', sanitizer, { memo: true }) customFields;
@field('broadcast') broadcast;
@@ -18,15 +18,15 @@ export default class Room extends Model {
@field('ro') ro;
- @json('v', sanitizer) v;
+ @json('v', sanitizer, { memo: true }) v;
- @json('served_by', sanitizer) servedBy;
+ @json('served_by', sanitizer, { memo: true }) servedBy;
@field('department_id') departmentId;
- @json('livechat_data', sanitizer) livechatData;
+ @json('livechat_data', sanitizer, { memo: true }) livechatData;
- @json('tags', sanitizer) tags;
+ @json('tags', sanitizer, { memo: true }) tags;
@field('avatar_etag') avatarETag;
}
diff --git a/app/lib/database/model/Setting.js b/app/lib/database/model/Setting.js
index 1597272bdb1..59c175a947b 100644
--- a/app/lib/database/model/Setting.js
+++ b/app/lib/database/model/Setting.js
@@ -14,7 +14,7 @@ export default class Setting extends Model {
@field('value_as_number') valueAsNumber;
- @json('value_as_array', sanitizer) valueAsArray;
+ @json('value_as_array', sanitizer, { memo: true }) valueAsArray;
@date('_updated_at') _updatedAt;
}
diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js
index 8c0db9d18ca..4c137faf466 100644
--- a/app/lib/database/model/Subscription.js
+++ b/app/lib/database/model/Subscription.js
@@ -37,7 +37,7 @@ export default class Subscription extends Model {
@field('alert') alert;
- @json('roles', sanitizer) roles;
+ @json('roles', sanitizer, { memo: true }) roles;
@field('unread') unread;
@@ -45,11 +45,11 @@ export default class Subscription extends Model {
@field('group_mentions') groupMentions;
- @json('tunread', sanitizer) tunread;
+ @json('tunread', sanitizer, { memo: true }) tunread;
- @json('tunread_user', sanitizer) tunreadUser;
+ @json('tunread_user', sanitizer, { memo: true }) tunreadUser;
- @json('tunread_group', sanitizer) tunreadGroup;
+ @json('tunread_group', sanitizer, { memo: true }) tunreadGroup;
@date('room_updated_at') roomUpdatedAt;
@@ -77,11 +77,11 @@ export default class Subscription extends Model {
@field('notifications') notifications;
- @json('muted', sanitizer) muted;
+ @json('muted', sanitizer, { memo: true }) muted;
- @json('unmuted', sanitizer) unmuted;
+ @json('unmuted', sanitizer, { memo: true }) unmuted;
- @json('ignored', sanitizer) ignored;
+ @json('ignored', sanitizer, { memo: true }) ignored;
@field('broadcast') broadcast;
@@ -97,7 +97,7 @@ export default class Subscription extends Model {
@field('auto_translate_language') autoTranslateLanguage;
- @json('last_message', sanitizer) lastMessage;
+ @json('last_message', sanitizer, { memo: true }) lastMessage;
@children('messages') messages;
@@ -109,25 +109,25 @@ export default class Subscription extends Model {
@field('hide_mention_status') hideMentionStatus;
- @json('sys_mes', sanitizer) sysMes;
+ @json('sys_mes', sanitizer, { memo: true }) sysMes;
- @json('uids', sanitizer) uids;
+ @json('uids', sanitizer, { memo: true }) uids;
- @json('usernames', sanitizer) usernames;
+ @json('usernames', sanitizer, { memo: true }) usernames;
- @json('visitor', sanitizer) visitor;
+ @json('visitor', sanitizer, { memo: true }) visitor;
@field('department_id') departmentId;
- @json('served_by', sanitizer) servedBy;
+ @json('served_by', sanitizer, { memo: true }) servedBy;
- @json('livechat_data', sanitizer) livechatData;
+ @json('livechat_data', sanitizer, { memo: true }) livechatData;
- @json('tags', sanitizer) tags;
+ @json('tags', sanitizer, { memo: true }) tags;
@field('e2e_key') E2EKey;
- @json('old_room_keys', sanitizer) oldRoomKeys;
+ @json('old_room_keys', sanitizer, { memo: true }) oldRoomKeys;
@field('e2e_suggested_key') E2ESuggestedKey;
@@ -135,7 +135,7 @@ export default class Subscription extends Model {
@field('e2e_key_id') e2eKeyId;
- @json('users_waiting_for_e2e_keys', sanitizer) usersWaitingForE2EKeys;
+ @json('users_waiting_for_e2e_keys', sanitizer, { memo: true }) usersWaitingForE2EKeys;
@field('avatar_etag') avatarETag;
@@ -147,19 +147,19 @@ export default class Subscription extends Model {
@field('users_count') usersCount;
- @json('source', sanitizer) source;
+ @json('source', sanitizer, { memo: true }) source;
@field('disable_notifications') disableNotifications;
@field('federated') federated;
- @json('abac_attributes', sanitizer) abacAttributes;
+ @json('abac_attributes', sanitizer, { memo: true }) abacAttributes;
- @json('federation', sanitizer) federation;
+ @json('federation', sanitizer, { memo: true }) federation;
@field('status') status;
- @json('inviter', sanitizer) inviter;
+ @json('inviter', sanitizer, { memo: true }) inviter;
asPlain() {
return {
diff --git a/app/lib/database/model/Thread.js b/app/lib/database/model/Thread.js
index a04e589bbda..4830cb12417 100644
--- a/app/lib/database/model/Thread.js
+++ b/app/lib/database/model/Thread.js
@@ -18,13 +18,13 @@ export default class Thread extends Model {
@date('ts') ts;
- @json('u', sanitizer) u;
+ @json('u', sanitizer, { memo: true }) u;
@relation('subscriptions', 'rid') subscription;
@field('alias') alias;
- @json('parse_urls', sanitizer) parseUrls;
+ @json('parse_urls', sanitizer, { memo: true }) parseUrls;
@field('groupable') groupable;
@@ -32,9 +32,9 @@ export default class Thread extends Model {
@field('emoji') emoji;
- @json('attachments', sanitizer) attachments;
+ @json('attachments', sanitizer, { memo: true }) attachments;
- @json('urls', sanitizer) urls;
+ @json('urls', sanitizer, { memo: true }) urls;
@date('_updated_at') _updatedAt;
@@ -44,9 +44,9 @@ export default class Thread extends Model {
@field('starred') starred;
- @json('edited_by', sanitizer) editedBy;
+ @json('edited_by', sanitizer, { memo: true }) editedBy;
- @json('reactions', sanitizer) reactions;
+ @json('reactions', sanitizer, { memo: true }) reactions;
@field('role') role;
@@ -62,19 +62,19 @@ export default class Thread extends Model {
@date('tlm') tlm;
- @json('replies', sanitizer) replies;
+ @json('replies', sanitizer, { memo: true }) replies;
- @json('mentions', sanitizer) mentions;
+ @json('mentions', sanitizer, { memo: true }) mentions;
- @json('channels', sanitizer) channels;
+ @json('channels', sanitizer, { memo: true }) channels;
@field('unread') unread;
@field('auto_translate') autoTranslate;
- @json('translations', sanitizer) translations;
+ @json('translations', sanitizer, { memo: true }) translations;
- @json('content', sanitizer) content;
+ @json('content', sanitizer, { memo: true }) content;
@field('e2e') e2e;
diff --git a/app/lib/database/model/ThreadMessage.js b/app/lib/database/model/ThreadMessage.js
index 8bb364b7edf..2bbd11e612d 100644
--- a/app/lib/database/model/ThreadMessage.js
+++ b/app/lib/database/model/ThreadMessage.js
@@ -18,7 +18,7 @@ export default class ThreadMessage extends Model {
@date('ts') ts;
- @json('u', sanitizer) u;
+ @json('u', sanitizer, { memo: true }) u;
@relation('subscriptions', 'subscription_id') subscription;
@@ -26,7 +26,7 @@ export default class ThreadMessage extends Model {
@field('alias') alias;
- @json('parse_urls', sanitizer) parseUrls;
+ @json('parse_urls', sanitizer, { memo: true }) parseUrls;
@field('groupable') groupable;
@@ -34,9 +34,9 @@ export default class ThreadMessage extends Model {
@field('emoji') emoji;
- @json('attachments', sanitizer) attachments;
+ @json('attachments', sanitizer, { memo: true }) attachments;
- @json('urls', sanitizer) urls;
+ @json('urls', sanitizer, { memo: true }) urls;
@date('_updated_at') _updatedAt;
@@ -46,9 +46,9 @@ export default class ThreadMessage extends Model {
@field('starred') starred;
- @json('edited_by', sanitizer) editedBy;
+ @json('edited_by', sanitizer, { memo: true }) editedBy;
- @json('reactions', sanitizer) reactions;
+ @json('reactions', sanitizer, { memo: true }) reactions;
@field('role') role;
@@ -62,23 +62,23 @@ export default class ThreadMessage extends Model {
@date('tlm') tlm;
- @json('replies', sanitizer) replies;
+ @json('replies', sanitizer, { memo: true }) replies;
- @json('mentions', sanitizer) mentions;
+ @json('mentions', sanitizer, { memo: true }) mentions;
- @json('channels', sanitizer) channels;
+ @json('channels', sanitizer, { memo: true }) channels;
@field('unread') unread;
@field('auto_translate') autoTranslate;
- @json('translations', sanitizer) translations;
+ @json('translations', sanitizer, { memo: true }) translations;
@field('draft_message') draftMessage;
@field('e2e') e2e;
- @json('content', sanitizer) content;
+ @json('content', sanitizer, { memo: true }) content;
asPlain() {
return {
diff --git a/app/lib/database/model/User.js b/app/lib/database/model/User.js
index 23978d1caf3..8c9531f610d 100644
--- a/app/lib/database/model/User.js
+++ b/app/lib/database/model/User.js
@@ -16,5 +16,5 @@ export default class User extends Model {
@field('avatar_etag') avatarETag;
- @json('roles', sanitizer) roles;
+ @json('roles', sanitizer, { memo: true }) roles;
}
diff --git a/app/lib/database/model/__tests__/Message.memo.test.ts b/app/lib/database/model/__tests__/Message.memo.test.ts
new file mode 100644
index 00000000000..b0c9c091ea6
--- /dev/null
+++ b/app/lib/database/model/__tests__/Message.memo.test.ts
@@ -0,0 +1,25 @@
+import fs from 'fs';
+import path from 'path';
+
+// MessageStore's Object.is/useShallow bail-outs depend on every @json field returning a stable
+// reference for an unchanged record. That stability comes only from WatermelonDB's `{ memo: true }`
+// decorator option (app/containers/message/stores/MessageStore.tsx:62-67). This is a source
+// characterization test: it can't observe the runtime accessor without an LDB mock, so it guards
+// the textual invariant instead.
+const messageSource = fs.readFileSync(path.join(__dirname, '../Message.js'), 'utf8');
+
+describe('Message.js @json memo invariant', () => {
+ const jsonDeclarations = messageSource.match(/@json\([^)]*\)/g) ?? [];
+
+ // Positive control: a regex that matches nothing would let every assertion below pass vacuously.
+ it('finds the expected number of @json fields', () => {
+ expect(jsonDeclarations.length).toBe(13);
+ });
+
+ it('declares { memo: true } on every @json field', () => {
+ expect(jsonDeclarations.length).toBeGreaterThan(0);
+ jsonDeclarations.forEach(declaration => {
+ expect(declaration).toContain('{ memo: true }');
+ });
+ });
+});
diff --git a/app/lib/database/model/servers/Server.js b/app/lib/database/model/servers/Server.js
index 2d57113e414..d9673269188 100644
--- a/app/lib/database/model/servers/Server.js
+++ b/app/lib/database/model/servers/Server.js
@@ -35,7 +35,7 @@ export default class Server extends Model {
@field('e2e_enable') E2E_Enable;
- @json('supported_versions', sanitizer) supportedVersions;
+ @json('supported_versions', sanitizer, { memo: true }) supportedVersions;
@date('supported_versions_warning_at') supportedVersionsWarningAt;
diff --git a/app/lib/database/model/servers/User.js b/app/lib/database/model/servers/User.js
index bf32f82e911..4acacbe385e 100644
--- a/app/lib/database/model/servers/User.js
+++ b/app/lib/database/model/servers/User.js
@@ -20,7 +20,7 @@ export default class User extends Model {
@field('statusText') statusText;
- @json('roles', sanitizer) roles;
+ @json('roles', sanitizer, { memo: true }) roles;
@field('avatar_etag') avatarETag;
diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts
index 04058659758..a837847923b 100644
--- a/app/lib/encryption/room.ts
+++ b/app/lib/encryption/room.ts
@@ -715,34 +715,34 @@ export default class EncryptionRoom {
async decryptQuoteAttachment(message: IMessage) {
const urls = message?.msg?.match(getMessageUrlRegex()) || [];
- await Promise.all(
- urls.map(async (url: string) => {
+ const quoteAttachments = await Promise.all(
+ urls.map(async (url: string): Promise => {
const parsedUrl = parse(url, true);
const messageId = parsedUrl.query?.msg;
if (!messageId) {
- return;
+ return null;
}
// From local db
const messageFromDB = await getMessageById(messageId);
if (messageFromDB && messageFromDB.e2e === 'done') {
const decryptedQuoteMessage = mapMessageFromDB(messageFromDB);
- message.attachments = message.attachments || [];
- const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url);
- return message.attachments.push(quoteAttachment);
+ return createQuoteAttachment(decryptedQuoteMessage, url);
}
// From API
const quotedMessageObject = await getSingleMessage(messageId);
if (!quotedMessageObject) {
- return;
+ return null;
}
const decryptedQuoteMessage = await this.decrypt(mapMessageFromAPI(quotedMessageObject));
- message.attachments = message.attachments || [];
- const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage as IMessage, url);
- return message.attachments.push(quoteAttachment);
+ return createQuoteAttachment(decryptedQuoteMessage as IMessage, url);
})
);
+ message.attachments = [
+ ...(message.attachments || []),
+ ...quoteAttachments.filter((attachment): attachment is IAttachment => attachment !== null)
+ ];
return message;
}
diff --git a/app/lib/hooks/useCustomEmoji.test.ts b/app/lib/hooks/useCustomEmoji.test.ts
index ff938f57b3b..70562350e5d 100644
--- a/app/lib/hooks/useCustomEmoji.test.ts
+++ b/app/lib/hooks/useCustomEmoji.test.ts
@@ -1,6 +1,7 @@
import { renderHook } from '@testing-library/react-native';
-import { resolveCustomEmoji, useCustomEmoji } from './useCustomEmoji';
+import { useCustomEmoji } from './useCustomEmoji';
+import { resolveCustomEmoji } from '../../definitions';
const customEmojis = { nyan_rocket: { name: 'nyan_rocket', extension: 'png' } };
diff --git a/app/lib/hooks/useCustomEmoji.ts b/app/lib/hooks/useCustomEmoji.ts
index 65ac1402495..6cc19cdd81f 100644
--- a/app/lib/hooks/useCustomEmoji.ts
+++ b/app/lib/hooks/useCustomEmoji.ts
@@ -1,8 +1,6 @@
-import { type ICustomEmojis, type TGetCustomEmoji } from '../../definitions';
+import { type TGetCustomEmoji, resolveCustomEmoji } from '../../definitions';
import { useAppSelector } from './useAppSelector';
-export const resolveCustomEmoji = (customEmojis: ICustomEmojis, name: string) => customEmojis[name] ?? null;
-
export const useCustomEmoji = (): TGetCustomEmoji => {
'use memo';
diff --git a/app/lib/services/voip/docs/README.md b/app/lib/services/voip/docs/README.md
index f39207f939e..f76c518a737 100644
--- a/app/lib/services/voip/docs/README.md
+++ b/app/lib/services/voip/docs/README.md
@@ -12,4 +12,4 @@ Entry point for documentation of the peer-to-peer audio call subsystem. VoIP is
## Glossary
-Domain terms used throughout these docs are defined in the project glossary at `../../../../UBIQUITOUS_LANGUAGE.md` under "Video & Voice".
+Domain terms used throughout these docs are defined in the project glossary at `../../../../CONTEXT.md` under "Video & Voice".
diff --git a/app/views/ForwardMessageView/index.tsx b/app/views/ForwardMessageView/index.tsx
index 0ab9c0690c9..739819b91c0 100644
--- a/app/views/ForwardMessageView/index.tsx
+++ b/app/views/ForwardMessageView/index.tsx
@@ -15,7 +15,7 @@ import SelectPersonOrChannel from './SelectPersonOrChannel';
import { useAppSelector } from '../../lib/hooks/useAppSelector';
import { type NewMessageStackParamList } from '../../stacks/types';
import { postMessage } from '../../lib/services/restApi';
-import MessagePreview from '../../containers/message/Preview';
+import MessagePreview from '../../containers/message/components/Preview';
import EventEmitter from '../../lib/methods/helpers/events';
import { LISTENER } from '../../containers/Toast';
diff --git a/app/views/MessagesView/index.tsx b/app/views/MessagesView/index.tsx
index a9ca795be42..4175ab9141a 100644
--- a/app/views/MessagesView/index.tsx
+++ b/app/views/MessagesView/index.tsx
@@ -8,6 +8,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 ActivityIndicator from '../../containers/ActivityIndicator';
import I18n from '../../i18n';
import getFileUrlAndTypeFromMessage from './getFileUrlAndTypeFromMessage';
@@ -19,9 +20,9 @@ import SafeAreaView from '../../containers/SafeAreaView';
import getThreadName from '../../lib/methods/getThreadName';
import styles from './styles';
import { type ChatsStackParamList } from '../../stacks/types';
-import { type IRoomInfoParam } from '../SearchMessagesView';
import {
type IApplicationState,
+ type IRoomInfoParam,
type TMessageModel,
type ISubscription,
SubscriptionType,
@@ -51,7 +52,6 @@ interface IMessagesViewProps {
route: RouteProp;
theme: TSupportedThemes;
showActionSheet: (params: { options: string[]; hasCancel: boolean }) => void;
- useRealName: boolean;
isMasterDetail: boolean;
insets: EdgeInsets;
}
@@ -160,22 +160,10 @@ class MessagesView extends Component {
};
defineMessagesViewContent = (name: string) => {
- const { user, baseUrl, theme, useRealName } = this.props;
+ const { user } = this.props;
const renderItemCommonProps = (item: TAnyMessageModel) => ({
item,
- baseUrl,
- user,
- author: item.u || item.user,
- timeFormat: 'MMM Do YYYY, h:mm:ss a',
- isEdited: !!item.editedAt,
- isHeader: true,
- isThreadRoom: true,
- attachments: item.attachments || [],
- useRealName,
- showAttachment: this.showAttachment,
- navToRoomInfo: this.navToRoomInfo,
- onPress: () => this.jumpToMessage({ item }),
- rid: this.rid
+ onPress: () => this.jumpToMessage({ item })
});
return {
@@ -194,7 +182,6 @@ class MessagesView extends Component {
renderItem: (item: any) => (
{
},
noDataMsg: I18n.t('No_mentioned_messages'),
testID: 'mentioned-messages-view',
- renderItem: (item: TAnyMessageModel) =>
+ renderItem: (item: TAnyMessageModel) =>
},
// Starred Messages Screen
Starred: {
@@ -232,7 +219,7 @@ class MessagesView extends Component {
noDataMsg: I18n.t('No_starred_messages'),
testID: 'starred-messages-view',
renderItem: (item: TAnyMessageModel) => (
- this.onLongPress(item)} theme={theme} />
+ this.onLongPress(item)} />
),
action: (message: IMessage) => ({
title: I18n.t('Unstar'),
@@ -251,7 +238,7 @@ class MessagesView extends Component {
noDataMsg: I18n.t('No_pinned_messages'),
testID: 'pinned-messages-view',
renderItem: (item: TAnyMessageModel) => (
- this.onLongPress(item)} theme={theme} />
+ this.onLongPress(item)} />
),
action: () => ({ title: I18n.t('Unpin'), icon: 'pin', onPress: this.handleActionPress }),
handleActionPress: (message: IMessage) => togglePinMessage(message._id, message.pinned)
@@ -347,7 +334,7 @@ class MessagesView extends Component {
render() {
const { messages, loading } = this.state;
- const { theme, insets } = this.props;
+ const { theme, user, baseUrl, insets } = this.props;
if (!loading && messages.length === 0) {
return this.renderEmpty();
@@ -355,15 +342,24 @@ 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}
+ />
+
);
}
@@ -371,8 +367,7 @@ class MessagesView extends Component {
const mapStateToProps = (state: IApplicationState) => ({
baseUrl: state.server.server,
- user: getUserSelector(state),
- useRealName: state.settings.UI_Use_Real_Name
+ user: getUserSelector(state)
});
export default connect(mapStateToProps)(withTheme(withActionSheet(withMasterDetail(withSafeAreaInsets(MessagesView)))));
diff --git a/app/views/ModalBlockView.tsx b/app/views/ModalBlockView.tsx
index 3e4faf3ae66..ae39435bf46 100644
--- a/app/views/ModalBlockView.tsx
+++ b/app/views/ModalBlockView.tsx
@@ -14,8 +14,11 @@ import Navigation from '../lib/navigation/appNavigation';
import { type MasterDetailInsideStackParamList } from '../stacks/MasterDetailStack/types';
import { ContainerTypes, ModalActions, type TModalAction } from '../containers/UIKit/interfaces';
import { triggerBlockAction, triggerCancel, triggerSubmitView } from '../lib/methods/triggerActions';
-import { type IApplicationState } from '../definitions';
+import { type IApplicationState, type TAnyMessageModel } from '../definitions';
import KeyboardView from '../containers/KeyboardView';
+import { MessageRoomProvider } from '../containers/message/stores/MessageRoomStore';
+import { MessageProvider } from '../containers/message/stores/MessageStore';
+import { getUserSelector } from '../selectors/login';
const styles = StyleSheet.create({
content: {
@@ -51,10 +54,8 @@ interface IModalBlockViewProps {
route: RouteProp;
theme: TSupportedThemes;
language: string;
- user: {
- id: string;
- token: string;
- };
+ user: ReturnType;
+ baseUrl: string;
}
// eslint-disable-next-line no-sequences
@@ -93,6 +94,13 @@ const LoadingIndicator = ({ loading }: { loading: boolean }) => {
return null;
};
+// 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
+// fake TAnyMessageModel fixtures) so the cast reads as intentional, not a real message.
+const EMPTY_MESSAGE = {} as unknown as TAnyMessageModel;
+
class ModalBlockView extends Component {
private submitting: boolean;
@@ -252,7 +260,7 @@ class ModalBlockView extends Component
-
+
+
+
+
+
@@ -283,7 +295,9 @@ class ModalBlockView extends Component ({
- language: state.login.user.language as string
+ language: state.login.user.language as string,
+ baseUrl: state.server.server,
+ user: getUserSelector(state)
});
export default connect(mapStateToProps)(ModalBlockView);
diff --git a/app/views/RoomView/LoadMore/LoadMore.stories.tsx b/app/views/RoomView/LoadMore/LoadMore.stories.tsx
index 0aafe71153c..efed137e4dd 100644
--- a/app/views/RoomView/LoadMore/LoadMore.stories.tsx
+++ b/app/views/RoomView/LoadMore/LoadMore.stories.tsx
@@ -2,7 +2,7 @@ import { ScrollView } from 'react-native';
import { longText } from '../../../../.rnstorybook/utils';
import { ThemeContext, type TSupportedThemes } from '../../../theme';
-import { Message } from '../../../containers/message/Message.stories';
+import { Message } from '../../../containers/message/components/stories/Message.stories';
import { MessageTypeLoad } from '../../../lib/constants/messageTypeLoad';
import { themes } from '../../../lib/constants/colors';
import LoadMoreComponent from '.';
diff --git a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap
index 0a5abbdf57c..0bc989b37bd 100644
--- a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap
+++ b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap
@@ -629,10 +629,11 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = `
"width": "100%",
}
}
- testID="message-undefined"
+ testID="message-story-message-1"
>
@@ -917,6 +917,7 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = `
}
>
@@ -2034,6 +2039,7 @@ exports[`Story Snapshots: BlackTheme should match snapshot 1`] = `
}
>