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