Make composer duck aware of the conversation it is in

This commit is contained in:
Josh Perez 2023-01-04 19:22:36 -05:00 committed by GitHub
parent 7a076be0e7
commit 198d6f7e26
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 449 additions and 170 deletions

View file

@ -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<void> {
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<void> {
!shiftKey &&
(key === 'p' || key === 'P')
) {
removeLinkPreview();
removeLinkPreview(conversation.id);
event.preventDefault();
event.stopPropagation();

View file

@ -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<File>;
}) => 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 ? (
<div className="CompositionArea__button-cell">
<MediaQualitySelector
conversationId={conversationId}
i18n={i18n}
isHighQuality={shouldSendHighQualityAttachments}
onSelectQuality={setMediaQualitySetting}
@ -672,7 +687,7 @@ export function CompositionArea({
<StagedLinkPreview
{...linkPreviewResult}
i18n={i18n}
onClose={onCloseLinkPreview}
onClose={() => onCloseLinkPreview(conversationId)}
/>
</div>
)}

View file

@ -18,6 +18,7 @@ export default {
const i18n = setupI18n('en', enMessages);
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
conversationId: 'abc123',
i18n,
isHighQuality: boolean('isHighQuality', Boolean(overrideProps.isHighQuality)),
onSelectQuality: action('onSelectQuality'),

View file

@ -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);
}}
>

View file

@ -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')}

View file

@ -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<void> {
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);
}

View file

@ -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<typeof actions> =>
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;
}

View file

@ -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<AttachmentDraftType>;
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<string, ComposerStateByConversationType>;
};
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<AttachmentDraftType>;
payload: {
conversationId: string;
attachments: ReadonlyArray<AttachmentDraftType>;
};
};
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<MessageAttributesType, 'conversationId' | 'quote'>;
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<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
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<void, RootStateType, unknown, ReplaceAttachmentsActionType> {
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<MessageAttributesType, 'conversationId' | 'quote' | 'payment'>
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<ComposerStateType>,
action: Readonly<ComposerActionType>,
getNextComposerState: (
prevState: ComposerStateByConversationType
) => Partial<ComposerStateByConversationType>
): 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<ComposerStateType> = getEmptyState(),
action: Readonly<ComposerActionType>
): ComposerStateType {
if (action.type === RESET_COMPOSER) {
if (action.type === CONVERSATION_UNLOADED) {
const nextConversations: Record<string, ComposerStateByConversationType> =
{};
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;

View file

@ -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;
}

View file

@ -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,
},
};
}

View file

@ -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();
}

View file

@ -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
);

View file

@ -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);

View file

@ -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);
});
});
});

View file

@ -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);
});

View file

@ -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,
},

View file

@ -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;
}>;