diff --git a/ts/background.ts b/ts/background.ts index c4c1e151d..4e5989e30 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -160,6 +160,7 @@ import { downloadOnboardingStory } from './util/downloadOnboardingStory'; import { clearConversationDraftAttachments } from './util/clearConversationDraftAttachments'; import { removeLinkPreview } from './services/LinkPreview'; import { PanelType } from './types/Panels'; +import { getQuotedMessageSelector } from './state/selectors/composer'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -1628,10 +1629,8 @@ export async function startApp(): Promise { const { selectedMessage } = state.conversations; - const composerState = window.reduxStore - ? window.reduxStore.getState().composer - : undefined; - const quote = composerState?.quotedMessage?.quote; + const quotedMessageSelector = getQuotedMessageSelector(state); + const quote = quotedMessageSelector(conversation.id); window.reduxActions.composer.setQuoteByMessageId( conversation.id, @@ -1708,7 +1707,7 @@ export async function startApp(): Promise { !shiftKey && (key === 'p' || key === 'P') ) { - removeLinkPreview(); + removeLinkPreview(conversation.id); event.preventDefault(); event.stopPropagation(); diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index f0bcf0947..389b30cfb 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -102,12 +102,12 @@ export type OwnProps = Readonly<{ linkPreviewResult?: LinkPreviewType; messageRequestsEnabled?: boolean; onClearAttachments(conversationId: string): unknown; - onCloseLinkPreview(): unknown; + onCloseLinkPreview(conversationId: string): unknown; processAttachments: (options: { conversationId: string; files: ReadonlyArray; }) => unknown; - setMediaQualitySetting(isHQ: boolean): unknown; + setMediaQualitySetting(conversationId: string, isHQ: boolean): unknown; sendStickerMessage( id: string, opts: { packId: string; stickerId: number } @@ -136,7 +136,7 @@ export type OwnProps = Readonly<{ ): unknown; shouldSendHighQualityAttachments: boolean; showConversation: ShowConversationType; - startRecording: () => unknown; + startRecording: (id: string) => unknown; theme: ThemeType; }>; @@ -366,6 +366,20 @@ export function CompositionArea({ [inputApiRef, onPickEmoji] ); + const previousConversationId = usePrevious(conversationId, conversationId); + useEffect(() => { + if (!draftText) { + inputApiRef.current?.setText(''); + return; + } + + if (conversationId === previousConversationId) { + return; + } + + inputApiRef.current?.setText(draftText, true); + }, [conversationId, draftText, previousConversationId]); + const handleToggleLarge = useCallback(() => { setLarge(l => !l); }, [setLarge]); @@ -391,6 +405,7 @@ export function CompositionArea({ {showMediaQualitySelector ? (
onCloseLinkPreview(conversationId)} />
)} diff --git a/ts/components/MediaQualitySelector.stories.tsx b/ts/components/MediaQualitySelector.stories.tsx index 6a3252bfd..a1277f351 100644 --- a/ts/components/MediaQualitySelector.stories.tsx +++ b/ts/components/MediaQualitySelector.stories.tsx @@ -18,6 +18,7 @@ export default { const i18n = setupI18n('en', enMessages); const createProps = (overrideProps: Partial = {}): PropsType => ({ + conversationId: 'abc123', i18n, isHighQuality: boolean('isHighQuality', Boolean(overrideProps.isHighQuality)), onSelectQuality: action('onSelectQuality'), diff --git a/ts/components/MediaQualitySelector.tsx b/ts/components/MediaQualitySelector.tsx index be78889e6..00c581aeb 100644 --- a/ts/components/MediaQualitySelector.tsx +++ b/ts/components/MediaQualitySelector.tsx @@ -12,12 +12,14 @@ import { useRefMerger } from '../hooks/useRefMerger'; import { handleOutsideClick } from '../util/handleOutsideClick'; export type PropsType = { + conversationId: string; i18n: LocalizerType; isHighQuality: boolean; - onSelectQuality: (isHQ: boolean) => unknown; + onSelectQuality: (conversationId: string, isHQ: boolean) => unknown; }; export function MediaQualitySelector({ + conversationId, i18n, isHighQuality, onSelectQuality, @@ -50,7 +52,7 @@ export function MediaQualitySelector({ } if (ev.key === 'Enter') { - onSelectQuality(Boolean(focusedOption)); + onSelectQuality(conversationId, Boolean(focusedOption)); setMenuShowing(false); ev.stopPropagation(); ev.preventDefault(); @@ -136,7 +138,7 @@ export function MediaQualitySelector({ })} type="button" onClick={() => { - onSelectQuality(false); + onSelectQuality(conversationId, false); setMenuShowing(false); }} > @@ -169,7 +171,7 @@ export function MediaQualitySelector({ })} type="button" onClick={() => { - onSelectQuality(true); + onSelectQuality(conversationId, true); setMenuShowing(false); }} > diff --git a/ts/components/conversation/AudioCapture.tsx b/ts/components/conversation/AudioCapture.tsx index 9cf3ea718..cfae45a3e 100644 --- a/ts/components/conversation/AudioCapture.tsx +++ b/ts/components/conversation/AudioCapture.tsx @@ -38,7 +38,7 @@ export type PropsType = { i18n: LocalizerType; recordingState: RecordingState; onSendAudioRecording: OnSendAudioRecordingType; - startRecording: () => unknown; + startRecording: (id: string) => unknown; }; enum ToastType { @@ -96,7 +96,11 @@ export function AudioCapture({ useEscapeHandling(escapeRecording); - const startRecordingShortcut = useStartRecordingShortcut(startRecording); + const recordConversation = useCallback( + () => startRecording(conversationId), + [conversationId, startRecording] + ); + const startRecordingShortcut = useStartRecordingShortcut(recordConversation); useKeyboardShortcuts(startRecordingShortcut); const closeToast = useCallback(() => { @@ -240,7 +244,7 @@ export function AudioCapture({ if (draftAttachments.length) { setToastType(ToastType.VoiceNoteMustBeOnlyAttachment); } else { - startRecording(); + startRecording(conversationId); } }} title={i18n('voiceRecording--start')} diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts index d39e92fea..caf4daa5f 100644 --- a/ts/services/LinkPreview.ts +++ b/ts/services/LinkPreview.ts @@ -26,6 +26,7 @@ import { dropNull } from '../util/dropNull'; import { fileToBytes } from '../util/fileToBytes'; import { maybeParseUrl } from '../util/url'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; +import { drop } from '../util/drop'; const LINK_PREVIEW_TIMEOUT = 60 * SECOND; @@ -48,7 +49,11 @@ export const maybeGrabLinkPreview = debounce(_maybeGrabLinkPreview, 200); function _maybeGrabLinkPreview( message: string, source: LinkPreviewSourceType, - { caretLocation, mode = 'conversation' }: MaybeGrabLinkPreviewOptionsType = {} + { + caretLocation, + conversationId, + mode = 'conversation', + }: MaybeGrabLinkPreviewOptionsType = {} ): void { // Don't generate link previews if user has turned them off. When posting a // story we should return minimal (url-only) link previews. @@ -67,7 +72,7 @@ function _maybeGrabLinkPreview( } if (!message) { - resetLinkPreview(); + resetLinkPreview(conversationId); return; } @@ -88,22 +93,25 @@ function _maybeGrabLinkPreview( LinkPreview.shouldPreviewHref(item) && !excludedPreviewUrls.includes(item) ); if (!link) { - removeLinkPreview(); + removeLinkPreview(conversationId); return; } - void addLinkPreview(link, source, { - disableFetch: !window.Events.getLinkPreviewSetting(), - }); + drop( + addLinkPreview(link, source, { + conversationId, + disableFetch: !window.Events.getLinkPreviewSetting(), + }) + ); } -export function resetLinkPreview(): void { +export function resetLinkPreview(conversationId?: string): void { disableLinkPreviews = false; excludedPreviewUrls = []; - removeLinkPreview(); + removeLinkPreview(conversationId); } -export function removeLinkPreview(): void { +export function removeLinkPreview(conversationId?: string): void { (linkPreviewResult || []).forEach((item: LinkPreviewResult) => { if (item.url) { URL.revokeObjectURL(item.url); @@ -114,13 +122,13 @@ export function removeLinkPreview(): void { linkPreviewAbortController?.abort(); linkPreviewAbortController = undefined; - window.reduxActions.linkPreviews.removeLinkPreview(); + window.reduxActions.linkPreviews.removeLinkPreview(conversationId); } export async function addLinkPreview( url: string, source: LinkPreviewSourceType, - { disableFetch }: AddLinkPreviewOptionsType = {} + { conversationId, disableFetch }: AddLinkPreviewOptionsType = {} ): Promise { if (currentlyMatchedLink === url) { log.warn('addLinkPreview should not be called with the same URL like this'); @@ -132,7 +140,7 @@ export async function addLinkPreview( URL.revokeObjectURL(item.url); } }); - window.reduxActions.linkPreviews.removeLinkPreview(); + window.reduxActions.linkPreviews.removeLinkPreview(conversationId); linkPreviewResult = undefined; // Cancel other in-flight link preview requests. @@ -156,7 +164,8 @@ export async function addLinkPreview( { url, }, - source + source, + conversationId ); try { @@ -186,7 +195,7 @@ export async function addLinkPreview( const failedToFetch = currentlyMatchedLink === url; if (failedToFetch) { excludedPreviewUrls.push(url); - removeLinkPreview(); + removeLinkPreview(conversationId); } return; } @@ -198,7 +207,7 @@ export async function addLinkPreview( result.image.url = URL.createObjectURL(blob); } else if (!result.title && !disableFetch) { // A link preview isn't worth showing unless we have either a title or an image - removeLinkPreview(); + removeLinkPreview(conversationId); return; } @@ -211,7 +220,8 @@ export async function addLinkPreview( domain: LinkPreview.getDomain(result.url), isStickerPack: LinkPreview.isStickerPack(result.url), }, - source + source, + conversationId ); linkPreviewResult = [result]; } catch (error) { @@ -220,7 +230,7 @@ export async function addLinkPreview( Errors.toLogFormat(error) ); disableLinkPreviews = true; - removeLinkPreview(); + removeLinkPreview(conversationId); } finally { clearTimeout(timeout); } diff --git a/ts/state/ducks/audioRecorder.ts b/ts/state/ducks/audioRecorder.ts index af68b2a10..c1151c689 100644 --- a/ts/state/ducks/audioRecorder.ts +++ b/ts/state/ducks/audioRecorder.ts @@ -12,6 +12,7 @@ import { recorder } from '../../services/audioRecorder'; import { stringToMIMEType } from '../../types/MIME'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import { useBoundActions } from '../../hooks/useBoundActions'; +import { getComposerStateForConversation } from './composer'; export enum ErrorDialogAudioRecorderType { Blur, @@ -80,17 +81,24 @@ export const actions = { export const useActions = (): BoundActionCreatorsMapObject => useBoundActions(actions); -function startRecording(): ThunkAction< +function startRecording( + conversationId: string +): ThunkAction< void, RootStateType, unknown, StartRecordingAction | NowRecordingAction | ErrorRecordingAction > { return async (dispatch, getState) => { - if (getState().composer.attachments.length) { + const state = getState(); + + if ( + getComposerStateForConversation(state.composer, conversationId) + .attachments.length + ) { return; } - if (getState().audioRecorder.recordingState !== RecordingState.Idle) { + if (state.audioRecorder.recordingState !== RecordingState.Idle) { return; } diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index ca9f84e67..4687123bd 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -71,14 +71,23 @@ import { getContactId } from '../../messages/helpers'; import { getConversationSelector } from '../selectors/conversations'; import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend'; import { useBoundActions } from '../../hooks/useBoundActions'; -import { scrollToMessage } from './conversations'; -import type { ScrollToMessageActionType } from './conversations'; +import { + CONVERSATION_UNLOADED, + SELECTED_CONVERSATION_CHANGED, + scrollToMessage, +} from './conversations'; +import type { + ConversationUnloadedActionType, + SelectedConversationChangedActionType, + ScrollToMessageActionType, +} from './conversations'; import { longRunningTaskWrapper } from '../../util/longRunningTaskWrapper'; import { drop } from '../../util/drop'; +import { strictAssert } from '../../util/assert'; // State -export type ComposerStateType = { +type ComposerStateByConversationType = { attachments: ReadonlyArray; focusCounter: number; isDisabled: boolean; @@ -89,6 +98,32 @@ export type ComposerStateType = { shouldSendHighQualityAttachments?: boolean; }; +export type QuotedMessageType = Pick< + MessageAttributesType, + 'conversationId' | 'quote' +>; + +export type ComposerStateType = { + conversations: Record; +}; + +function getEmptyComposerState(): ComposerStateByConversationType { + return { + attachments: [], + focusCounter: 0, + isDisabled: false, + linkPreviewLoading: false, + messageCompositionId: UUID.generate().toString(), + }; +} + +export function getComposerStateForConversation( + composer: ComposerStateType, + conversationId: string +): ComposerStateByConversationType { + return composer.conversations[conversationId] ?? getEmptyComposerState(); +} + // Actions const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT'; @@ -101,43 +136,66 @@ const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED'; type AddPendingAttachmentActionType = { type: typeof ADD_PENDING_ATTACHMENT; - payload: AttachmentDraftType; + payload: { + conversationId: string; + attachment: AttachmentDraftType; + }; }; export type ReplaceAttachmentsActionType = { type: typeof REPLACE_ATTACHMENTS; - payload: ReadonlyArray; + payload: { + conversationId: string; + attachments: ReadonlyArray; + }; }; export type ResetComposerActionType = { type: typeof RESET_COMPOSER; + payload: { + conversationId: string; + }; }; type SetComposerDisabledStateActionType = { type: typeof SET_COMPOSER_DISABLED; - payload: boolean; + payload: { + conversationId: string; + value: boolean; + }; }; export type SetFocusActionType = { type: typeof SET_FOCUS; + payload: { + conversationId: string; + }; }; type SetHighQualitySettingActionType = { type: typeof SET_HIGH_QUALITY_SETTING; - payload: boolean; + payload: { + conversationId: string; + value: boolean; + }; }; export type SetQuotedMessageActionType = { type: typeof SET_QUOTED_MESSAGE; - payload?: Pick; + payload: { + conversationId: string; + quotedMessage?: QuotedMessageType; + }; }; type ComposerActionType = | AddLinkPreviewActionType | AddPendingAttachmentActionType + | ConversationUnloadedActionType | RemoveLinkPreviewActionType | ReplaceAttachmentsActionType | ResetComposerActionType + | SelectedConversationChangedActionType | SetComposerDisabledStateActionType | SetFocusActionType | SetHighQualitySettingActionType @@ -207,9 +265,9 @@ function cancelJoinRequest(conversationId: string): NoopActionType { }; } -function onCloseLinkPreview(): NoopActionType { +function onCloseLinkPreview(conversationId: string): NoopActionType { suspendLinkPreviews(); - removeLinkPreview(); + removeLinkPreview(conversationId); return { type: 'NOOP', @@ -308,18 +366,18 @@ function sendMultiMediaMessage( ]); try { - dispatch(setComposerDisabledState(true)); + dispatch(setComposerDisabledState(conversationId, true)); const sendAnyway = await blockSendUntilConversationsAreVerified( recipientsByConversation, SafetyNumberChangeSource.MessageSend ); if (!sendAnyway) { - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); return; } } catch (error) { - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); log.error('sendMessage error:', Errors.toLogFormat(error)); return; } @@ -334,7 +392,7 @@ function sendMultiMediaMessage( toastType, }, }); - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); return; } @@ -345,7 +403,7 @@ function sendMultiMediaMessage( }) && !voiceNoteAttachment ) { - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); return; } @@ -359,10 +417,15 @@ function sendMultiMediaMessage( ).filter(isNotNil); } - const quote = state.composer.quotedMessage?.quote; + const conversationComposerState = getComposerStateForConversation( + state.composer, + conversationId + ); + + const quote = conversationComposerState.quotedMessage?.quote; const shouldSendHighQualityAttachments = window.reduxStore - ? state.composer.shouldSendHighQualityAttachments + ? conversationComposerState.shouldSendHighQualityAttachments : undefined; const sendHQImages = @@ -388,7 +451,7 @@ function sendMultiMediaMessage( // We rely on enqueueMessageForSend to call these within redux's batch extraReduxActions: () => { conversation.setMarkedUnread(false); - resetLinkPreview(); + resetLinkPreview(conversationId); drop( clearConversationDraftAttachments( conversationId, @@ -400,8 +463,8 @@ function sendMultiMediaMessage( getState, undefined ); - dispatch(resetComposer()); - dispatch(setComposerDisabledState(false)); + dispatch(resetComposer(conversationId)); + dispatch(setComposerDisabledState(conversationId, false)); }, } ); @@ -410,7 +473,7 @@ function sendMultiMediaMessage( 'Error pulling attached files before send', Errors.toLogFormat(error) ); - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); } }; } @@ -544,16 +607,16 @@ export function setQuoteByMessageId( } dispatch( - setQuotedMessage({ + setQuotedMessage(conversationId, { conversationId, quote, }) ); dispatch(setComposerFocus(conversation.id)); - dispatch(setComposerDisabledState(false)); + dispatch(setComposerDisabledState(conversationId, false)); } else { - dispatch(setQuotedMessage(undefined)); + dispatch(setQuotedMessage(conversationId, undefined)); } }; } @@ -567,11 +630,18 @@ function addAttachment( // each other. const onDisk = await writeDraftAttachment(attachment); + const state = getState(); + const isSelectedConversation = - getState().conversations.selectedConversationId === conversationId; + state.conversations.selectedConversationId === conversationId; + + const conversationComposerState = getComposerStateForConversation( + state.composer, + conversationId + ); const draftAttachments = isSelectedConversation - ? getState().composer.attachments + ? conversationComposerState.attachments : getAttachmentsFromConversationModel(conversationId); // We expect there to either be a pending draft attachment or an existing @@ -619,18 +689,28 @@ function addPendingAttachment( pendingAttachment: AttachmentDraftType ): ThunkAction { return (dispatch, getState) => { + const state = getState(); + const isSelectedConversation = - getState().conversations.selectedConversationId === conversationId; + state.conversations.selectedConversationId === conversationId; + + const conversationComposerState = getComposerStateForConversation( + state.composer, + conversationId + ); const draftAttachments = isSelectedConversation - ? getState().composer.attachments + ? conversationComposerState.attachments : getAttachmentsFromConversationModel(conversationId); const nextAttachments = [...draftAttachments, pendingAttachment]; dispatch({ type: REPLACE_ATTACHMENTS, - payload: nextAttachments, + payload: { + conversationId, + attachments: nextAttachments, + }, }); const conversation = window.ConversationController.get(conversationId); @@ -651,6 +731,9 @@ export function setComposerFocus( dispatch({ type: SET_FOCUS, + payload: { + conversationId, + }, }); }; } @@ -686,6 +769,7 @@ function onEditorStateChange( ) { maybeGrabLinkPreview(messageText, LinkPreviewSourceType.Composer, { caretLocation, + conversationId, }); } @@ -877,7 +961,12 @@ function removeAttachment( filePath: string ): ThunkAction { return async (dispatch, getState) => { - const { attachments } = getState().composer; + const state = getState(); + + const { attachments } = getComposerStateForConversation( + state.composer, + conversationId + ); const [targetAttachment] = attachments.filter( attachment => attachment.path === filePath @@ -924,12 +1013,15 @@ export function replaceAttachments( } if (hasDraftAttachments(attachments, { includePending: true })) { - removeLinkPreview(); + removeLinkPreview(conversationId); } dispatch({ type: REPLACE_ATTACHMENTS, - payload: attachments.map(resolveDraftAttachmentOnDisk), + payload: { + conversationId, + attachments: attachments.map(resolveDraftAttachmentOnDisk), + }, }); }; } @@ -972,9 +1064,12 @@ function reactToMessage( }; } -export function resetComposer(): ResetComposerActionType { +export function resetComposer(conversationId: string): ResetComposerActionType { return { type: RESET_COMPOSER, + payload: { + conversationId, + }, }; } const debouncedSaveDraft = debounce(saveDraft); @@ -1024,29 +1119,41 @@ function saveDraft( } function setComposerDisabledState( + conversationId: string, value: boolean ): SetComposerDisabledStateActionType { return { type: SET_COMPOSER_DISABLED, - payload: value, + payload: { + conversationId, + value, + }, }; } function setMediaQualitySetting( - payload: boolean + conversationId: string, + value: boolean ): SetHighQualitySettingActionType { return { type: SET_HIGH_QUALITY_SETTING, - payload, + payload: { + conversationId, + value, + }, }; } function setQuotedMessage( - payload?: Pick + conversationId: string, + quotedMessage?: QuotedMessageType ): SetQuotedMessageActionType { return { type: SET_QUOTED_MESSAGE, - payload, + payload: { + conversationId, + quotedMessage, + }, }; } @@ -1054,52 +1161,107 @@ function setQuotedMessage( export function getEmptyState(): ComposerStateType { return { - attachments: [], - focusCounter: 0, - isDisabled: false, - linkPreviewLoading: false, - messageCompositionId: UUID.generate().toString(), + conversations: {}, }; } +function updateComposerState( + state: Readonly, + action: Readonly, + getNextComposerState: ( + prevState: ComposerStateByConversationType + ) => Partial +): ComposerStateType { + const { conversationId } = action.payload; + + strictAssert( + conversationId, + 'updateComposerState: no conversationId provided' + ); + + const prevComposerState = getComposerStateForConversation( + state, + conversationId + ); + + const nextComposerStateForConversation = assignWithNoUnnecessaryAllocation( + prevComposerState, + getNextComposerState(prevComposerState) + ); + + return assignWithNoUnnecessaryAllocation(state, { + conversations: assignWithNoUnnecessaryAllocation(state.conversations, { + [conversationId]: nextComposerStateForConversation, + }), + }); +} + export function reducer( state: Readonly = getEmptyState(), action: Readonly ): ComposerStateType { - if (action.type === RESET_COMPOSER) { + if (action.type === CONVERSATION_UNLOADED) { + const nextConversations: Record = + {}; + Object.keys(state.conversations).forEach(conversationId => { + if (conversationId === action.payload.conversationId) { + return; + } + + nextConversations[conversationId] = state.conversations[conversationId]; + }); + + return { + ...state, + conversations: nextConversations, + }; + } + + if (action.type === SELECTED_CONVERSATION_CHANGED) { + if (action.payload.conversationId) { + return { + ...state, + conversations: { + [action.payload.conversationId]: getEmptyComposerState(), + }, + }; + } + return getEmptyState(); } + if (action.type === RESET_COMPOSER) { + return updateComposerState(state, action, () => ({})); + } + if (action.type === REPLACE_ATTACHMENTS) { - const { payload: attachments } = action; - return { - ...state, + const { attachments } = action.payload; + + return updateComposerState(state, action, () => ({ attachments, ...(attachments.length ? {} : { shouldSendHighQualityAttachments: undefined }), - }; + })); } if (action.type === SET_FOCUS) { - return { - ...state, - focusCounter: state.focusCounter + 1, - }; + return updateComposerState(state, action, prevState => ({ + focusCounter: prevState.focusCounter + 1, + })); } if (action.type === SET_HIGH_QUALITY_SETTING) { - return { - ...state, - shouldSendHighQualityAttachments: action.payload, - }; + return updateComposerState(state, action, () => ({ + shouldSendHighQualityAttachments: action.payload.value, + })); } if (action.type === SET_QUOTED_MESSAGE) { - return { - ...state, - quotedMessage: action.payload, - }; + const { quotedMessage } = action.payload; + return updateComposerState(state, action, () => ({ + quotedMessage, + })); } if (action.type === ADD_LINK_PREVIEW) { @@ -1107,32 +1269,29 @@ export function reducer( return state; } - return { - ...state, + return updateComposerState(state, action, () => ({ linkPreviewLoading: true, linkPreviewResult: action.payload.linkPreview, - }; + })); } if (action.type === REMOVE_LINK_PREVIEW) { - return assignWithNoUnnecessaryAllocation(state, { + return updateComposerState(state, action, () => ({ linkPreviewLoading: false, linkPreviewResult: undefined, - }); + })); } if (action.type === ADD_PENDING_ATTACHMENT) { - return { - ...state, - attachments: [...state.attachments, action.payload], - }; + return updateComposerState(state, action, prevState => ({ + attachments: [...prevState.attachments, action.payload.attachment], + })); } if (action.type === SET_COMPOSER_DISABLED) { - return { - ...state, - isDisabled: action.payload, - }; + return updateComposerState(state, action, () => ({ + isDisabled: action.payload.value, + })); } return state; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 294c9a92c..9ce06f0ba 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -612,7 +612,7 @@ export type ConversationRemovedActionType = { export type ConversationUnloadedActionType = { type: typeof CONVERSATION_UNLOADED; payload: { - id: string; + conversationId: string; }; }; type CreateGroupPendingActionType = { @@ -745,7 +745,7 @@ export type ClearUnreadMetricsActionType = { export type SelectedConversationChangedActionType = { type: typeof SELECTED_CONVERSATION_CHANGED; payload: { - id?: string; + conversationId?: string; messageId?: string; switchToAssociatedView?: boolean; }; @@ -3485,7 +3485,7 @@ function showConversation({ return { type: SELECTED_CONVERSATION_CHANGED, payload: { - id: conversationId, + conversationId, messageId, switchToAssociatedView, }, @@ -3581,7 +3581,7 @@ function onConversationOpened( conversation.get('id'), conversation.get('draftAttachments') || [] )(dispatch, getState, undefined); - dispatch(resetComposer()); + dispatch(resetComposer(conversationId)); }; } @@ -3625,13 +3625,13 @@ function onConversationClosed( drop(conversation.updateLastMessage()); } - removeLinkPreview(); + removeLinkPreview(conversationId); suspendLinkPreviews(); dispatch({ type: CONVERSATION_UNLOADED, payload: { - id: conversationId, + conversationId, }, }); }; @@ -4186,15 +4186,15 @@ export function reducer( } if (action.type === CONVERSATION_UNLOADED) { const { payload } = action; - const { id } = payload; - const existingConversation = state.messagesByConversation[id]; + const { conversationId } = payload; + const existingConversation = state.messagesByConversation[conversationId]; if (!existingConversation) { return state; } const { messageIds } = existingConversation; const selectedConversationId = - state.selectedConversationId !== id + state.selectedConversationId !== conversationId ? state.selectedConversationId : undefined; @@ -4203,7 +4203,9 @@ export function reducer( selectedConversationId, selectedConversationPanels: [], messagesLookup: omit(state.messagesLookup, [...messageIds]), - messagesByConversation: omit(state.messagesByConversation, [id]), + messagesByConversation: omit(state.messagesByConversation, [ + conversationId, + ]), }; } if (action.type === 'CONVERSATIONS_REMOVE_ALL') { @@ -4993,17 +4995,17 @@ export function reducer( } if (action.type === SELECTED_CONVERSATION_CHANGED) { const { payload } = action; - const { id, messageId, switchToAssociatedView } = payload; + const { conversationId, messageId, switchToAssociatedView } = payload; const nextState = { ...omit(state, 'contactSpoofingReview'), - selectedConversationId: id, + selectedConversationId: conversationId, selectedMessage: messageId, selectedMessageSource: SelectedMessageSource.NavigateToMessage, }; - if (switchToAssociatedView && id) { - const conversation = getOwn(state.conversationLookup, id); + if (switchToAssociatedView && conversationId) { + const conversation = getOwn(state.conversationLookup, conversationId); if (!conversation) { return nextState; } diff --git a/ts/state/ducks/linkPreviews.ts b/ts/state/ducks/linkPreviews.ts index 6336868c5..6dc177379 100644 --- a/ts/state/ducks/linkPreviews.ts +++ b/ts/state/ducks/linkPreviews.ts @@ -3,16 +3,15 @@ import type { ThunkAction } from 'redux-thunk'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import type { LinkPreviewType } from '../../types/message/LinkPreviews'; +import type { MaybeGrabLinkPreviewOptionsType } from '../../types/LinkPreview'; import type { NoopActionType } from './noop'; import type { StateType as RootStateType } from '../reducer'; -import type { LinkPreviewType } from '../../types/message/LinkPreviews'; -import type { - LinkPreviewSourceType, - MaybeGrabLinkPreviewOptionsType, -} from '../../types/LinkPreview'; +import { LinkPreviewSourceType } from '../../types/LinkPreview'; import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation'; import { maybeGrabLinkPreview } from '../../services/LinkPreview'; -import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import { strictAssert } from '../../util/assert'; import { useBoundActions } from '../../hooks/useBoundActions'; // State @@ -30,6 +29,7 @@ export const REMOVE_PREVIEW = 'linkPreviews/REMOVE_PREVIEW'; export type AddLinkPreviewActionType = { type: 'linkPreviews/ADD_PREVIEW'; payload: { + conversationId?: string; linkPreview: LinkPreviewType; source: LinkPreviewSourceType; }; @@ -37,6 +37,9 @@ export type AddLinkPreviewActionType = { export type RemoveLinkPreviewActionType = { type: 'linkPreviews/REMOVE_PREVIEW'; + payload: { + conversationId?: string; + }; }; type LinkPreviewsActionType = @@ -62,20 +65,31 @@ function debouncedMaybeGrabLinkPreview( function addLinkPreview( linkPreview: LinkPreviewType, - source: LinkPreviewSourceType + source: LinkPreviewSourceType, + conversationId?: string ): AddLinkPreviewActionType { + if (source === LinkPreviewSourceType.Composer) { + strictAssert(conversationId, 'no conversationId provided'); + } + return { type: ADD_PREVIEW, payload: { + conversationId, linkPreview, source, }, }; } -function removeLinkPreview(): RemoveLinkPreviewActionType { +function removeLinkPreview( + conversationId?: string +): RemoveLinkPreviewActionType { return { type: REMOVE_PREVIEW, + payload: { + conversationId, + }, }; } diff --git a/ts/state/ducks/search.ts b/ts/state/ducks/search.ts index 1fb3b7230..961bf600e 100644 --- a/ts/state/ducks/search.ts +++ b/ts/state/ducks/search.ts @@ -31,7 +31,10 @@ import { getUserConversationId, } from '../selectors/user'; import { strictAssert } from '../../util/assert'; -import { SELECTED_CONVERSATION_CHANGED } from './conversations'; +import { + CONVERSATION_UNLOADED, + SELECTED_CONVERSATION_CHANGED, +} from './conversations'; const { searchMessages: dataSearchMessages, @@ -437,10 +440,10 @@ export function reducer( if (action.type === SELECTED_CONVERSATION_CHANGED) { const { payload } = action; - const { id, messageId } = payload; + const { conversationId, messageId } = payload; const { searchConversationId } = state; - if (searchConversationId && searchConversationId !== id) { + if (searchConversationId && searchConversationId !== conversationId) { return getEmptyState(); } @@ -450,12 +453,12 @@ export function reducer( }; } - if (action.type === 'CONVERSATION_UNLOADED') { + if (action.type === CONVERSATION_UNLOADED) { const { payload } = action; - const { id } = payload; + const { conversationId } = payload; const { searchConversationId } = state; - if (searchConversationId && searchConversationId === id) { + if (searchConversationId && searchConversationId === conversationId) { return getEmptyState(); } diff --git a/ts/state/selectors/composer.ts b/ts/state/selectors/composer.ts new file mode 100644 index 000000000..f7a62eae6 --- /dev/null +++ b/ts/state/selectors/composer.ts @@ -0,0 +1,24 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; + +import type { StateType } from '../reducer'; +import type { ComposerStateType, QuotedMessageType } from '../ducks/composer'; +import { getComposerStateForConversation } from '../ducks/composer'; + +export const getComposerState = (state: StateType): ComposerStateType => + state.composer; + +export const getComposerStateForConversationIdSelector = createSelector( + getComposerState, + composer => (conversationId: string) => + getComposerStateForConversation(composer, conversationId) +); + +export const getQuotedMessageSelector = createSelector( + getComposerStateForConversationIdSelector, + composerStateForConversationIdSelector => + (conversationId: string): QuotedMessageType | undefined => + composerStateForConversationIdSelector(conversationId).quotedMessage +); diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index d11b3ff6b..584f4c717 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -30,6 +30,7 @@ import { getRecentStickers, } from '../selectors/stickers'; import { isSignalConversation } from '../../util/isSignalConversation'; +import { getComposerStateForConversationIdSelector } from '../selectors/composer'; type ExternalProps = { id: string; @@ -67,6 +68,9 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { receivedPacks.length > 0 ); + const composerStateForConversationIdSelector = + getComposerStateForConversationIdSelector(state); + const { attachments: draftAttachments, focusCounter, @@ -76,7 +80,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { messageCompositionId, quotedMessage, shouldSendHighQualityAttachments, - } = state.composer; + } = composerStateForConversationIdSelector(id); const recentEmojis = selectRecentEmojis(state); diff --git a/ts/test-both/state/ducks/composer_test.ts b/ts/test-both/state/ducks/composer_test.ts index 25ed893ca..ed28f4640 100644 --- a/ts/test-both/state/ducks/composer_test.ts +++ b/ts/test-both/state/ducks/composer_test.ts @@ -6,7 +6,12 @@ import * as sinon from 'sinon'; import { noop } from 'lodash'; import type { ReduxActions } from '../../../state/types'; -import { actions, getEmptyState, reducer } from '../../../state/ducks/composer'; +import { + actions, + getComposerStateForConversation, + getEmptyState, + reducer, +} from '../../../state/ducks/composer'; import { noopAction } from '../../../state/ducks/noop'; import { reducer as rootReducer } from '../../../state/reducer'; @@ -28,7 +33,7 @@ describe('both/state/ducks/composer', () => { }, }; - const getRootStateFunction = (selectedConversationId?: string) => { + function getRootStateFunction(selectedConversationId?: string) { const state = rootReducer(undefined, noopAction()); return () => ({ ...state, @@ -37,7 +42,7 @@ describe('both/state/ducks/composer', () => { selectedConversationId, }, }); - }; + } describe('replaceAttachments', () => { let oldReduxActions: ReduxActions; @@ -76,7 +81,8 @@ describe('both/state/ducks/composer', () => { const action = dispatch.getCall(0).args[0]; const state = reducer(getEmptyState(), action); - assert.deepEqual(state.attachments, attachments); + const composerState = getComposerStateForConversation(state, '123'); + assert.deepEqual(composerState.attachments, attachments); }); it('sets the high quality setting to false when there are no attachments', () => { @@ -94,14 +100,20 @@ describe('both/state/ducks/composer', () => { const state = reducer( { ...getEmptyState(), - shouldSendHighQualityAttachments: true, + conversations: { + '123': { + ...getComposerStateForConversation(getEmptyState(), '123'), + shouldSendHighQualityAttachments: true, + }, + }, }, action ); - assert.deepEqual(state.attachments, attachments); + const composerState = getComposerStateForConversation(state, '123'); + assert.deepEqual(composerState.attachments, attachments); - assert.deepEqual(state.attachments, attachments); - assert.isUndefined(state.shouldSendHighQualityAttachments); + assert.deepEqual(composerState.attachments, attachments); + assert.isUndefined(composerState.shouldSendHighQualityAttachments); }); it('does not update redux if the conversation is not selected', () => { @@ -122,23 +134,17 @@ describe('both/state/ducks/composer', () => { describe('resetComposer', () => { it('returns composer back to empty state', () => { const { resetComposer } = actions; - const emptyState = getEmptyState(); - const nextState = reducer( - { - attachments: [], - focusCounter: 0, - isDisabled: false, - linkPreviewLoading: true, - messageCompositionId: emptyState.messageCompositionId, - quotedMessage: QUOTED_MESSAGE, - shouldSendHighQualityAttachments: true, - }, - resetComposer() - ); + const nextState = reducer(getEmptyState(), resetComposer('456')); + const composerState = getComposerStateForConversation(nextState, '456'); assert.deepEqual(nextState, { ...getEmptyState(), - messageCompositionId: nextState.messageCompositionId, + conversations: { + '456': { + ...composerState, + messageCompositionId: composerState.messageCompositionId, + }, + }, }); }); }); @@ -148,15 +154,40 @@ describe('both/state/ducks/composer', () => { const { setMediaQualitySetting } = actions; const state = getEmptyState(); - assert.isUndefined(state.shouldSendHighQualityAttachments); + const composerState = getComposerStateForConversation(state, '123'); + assert.isUndefined(composerState.shouldSendHighQualityAttachments); - const nextState = reducer(state, setMediaQualitySetting(true)); + const nextState = reducer(state, setMediaQualitySetting('123', true)); - assert.isTrue(nextState.shouldSendHighQualityAttachments); + const nextComposerState = getComposerStateForConversation( + nextState, + '123' + ); + assert.isTrue(nextComposerState.shouldSendHighQualityAttachments); - const nextNextState = reducer(nextState, setMediaQualitySetting(false)); + const nextNextState = reducer( + nextState, + setMediaQualitySetting('123', false) + ); + const nextNextComposerState = getComposerStateForConversation( + nextNextState, + '123' + ); - assert.isFalse(nextNextState.shouldSendHighQualityAttachments); + assert.isFalse(nextNextComposerState.shouldSendHighQualityAttachments); + + const notMyConvoState = reducer( + nextNextState, + setMediaQualitySetting('456', true) + ); + const notMineComposerState = getComposerStateForConversation( + notMyConvoState, + '123' + ); + assert.isFalse( + notMineComposerState.shouldSendHighQualityAttachments, + 'still false for prev convo' + ); }); }); @@ -164,10 +195,11 @@ describe('both/state/ducks/composer', () => { it('sets the quoted message', () => { const { setQuotedMessage } = actions; const state = getEmptyState(); - const nextState = reducer(state, setQuotedMessage(QUOTED_MESSAGE)); + const nextState = reducer(state, setQuotedMessage('123', QUOTED_MESSAGE)); - assert.equal(nextState.quotedMessage?.conversationId, '123'); - assert.equal(nextState.quotedMessage?.quote?.id, 456); + const composerState = getComposerStateForConversation(nextState, '123'); + assert.equal(composerState.quotedMessage?.conversationId, '123'); + assert.equal(composerState.quotedMessage?.quote?.id, 456); }); }); }); diff --git a/ts/test-both/state/ducks/linkPreviews_test.ts b/ts/test-both/state/ducks/linkPreviews_test.ts index d31c84f4f..951c11dc0 100644 --- a/ts/test-both/state/ducks/linkPreviews_test.ts +++ b/ts/test-both/state/ducks/linkPreviews_test.ts @@ -26,7 +26,7 @@ describe('both/state/ducks/linkPreviews', () => { it('updates linkPreview', () => { const state = getEmptyState(); const linkPreview = getMockLinkPreview(); - const nextState = reducer(state, addLinkPreview(linkPreview, 0)); + const nextState = reducer(state, addLinkPreview(linkPreview, 1)); assert.strictEqual(nextState.linkPreview, linkPreview); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 6ac7af2e2..08730d064 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -741,7 +741,7 @@ describe('both/state/ducks/conversations', () => { sinon.assert.calledWith(dispatch, { type: SELECTED_CONVERSATION_CHANGED, payload: { - id: '9876', + conversationId: '9876', messageId: undefined, switchToAssociatedView: true, }, diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts index 04efa649a..44e4357c0 100644 --- a/ts/types/LinkPreview.ts +++ b/ts/types/LinkPreview.ts @@ -34,10 +34,12 @@ export enum LinkPreviewSourceType { export type MaybeGrabLinkPreviewOptionsType = Readonly<{ caretLocation?: number; + conversationId?: string; mode?: 'conversation' | 'story'; }>; export type AddLinkPreviewOptionsType = Readonly<{ + conversationId?: string; disableFetch?: boolean; }>;