Eliminate resetEmojiResults, move onEditorStateChanged to redux

This commit is contained in:
Scott Nonnenberg 2022-12-08 15:56:17 -08:00 committed by GitHub
parent 6d868030ae
commit 5c059c54d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 155 deletions

View file

@ -1506,7 +1506,7 @@ export async function startApp(): Promise<void> {
shiftKey &&
(key === 't' || key === 'T')
) {
conversation.trigger('focus-composer');
window.reduxActions.composer.setComposerFocus(conversation.id);
event.preventDefault();
event.stopPropagation();
return;

View file

@ -34,6 +34,7 @@ export default {
const useProps = (overrideProps: Partial<Props> = {}): Props => ({
addAttachment: action('addAttachment'),
conversationId: '123',
focusCounter: 0,
i18n,
isDisabled: false,
messageCompositionId: '456',
@ -41,6 +42,7 @@ const useProps = (overrideProps: Partial<Props> = {}): Props => ({
processAttachments: action('processAttachments'),
removeAttachment: action('removeAttachment'),
theme: React.useContext(StorybookThemeContext),
setComposerFocus: action('setComposerFocus'),
// AttachmentList
draftAttachments: overrideProps.draftAttachments || [],

View file

@ -1,7 +1,6 @@
// Copyright 2019-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MutableRefObject } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { get } from 'lodash';
import classNames from 'classnames';
@ -60,14 +59,6 @@ import { isImageTypeSupported } from '../util/GoogleChrome';
import * as KeyboardLayout from '../services/keyboardLayout';
import { usePrevious } from '../hooks/usePrevious';
export type CompositionAPIType =
| {
focusInput: () => void;
isDirty: () => boolean;
resetEmojiResults: InputApi['resetEmojiResults'];
}
| undefined;
export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean;
addAttachment: (
@ -83,12 +74,12 @@ export type OwnProps = Readonly<{
conversationId: string,
onSendAudioRecording?: (rec: InMemoryAttachmentDraftType) => unknown
) => unknown;
compositionApi?: MutableRefObject<CompositionAPIType>;
conversationId: string;
uuid?: string;
draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number;
groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2;
i18n: LocalizerType;
@ -133,6 +124,7 @@ export type OwnProps = Readonly<{
'i18n' | 'onClick' | 'onClose' | 'withContentAbove'
>;
removeAttachment: (conversationId: string, filePath: string) => unknown;
setComposerFocus: (conversationId: string) => unknown;
setQuotedMessage(message: undefined): unknown;
shouldSendHighQualityAttachments: boolean;
startRecording: () => unknown;
@ -177,14 +169,16 @@ export function CompositionArea({
// Base props
addAttachment,
conversationId,
focusCounter,
i18n,
imageToBlurHash,
isDisabled,
isSignalConversation,
messageCompositionId,
processAttachments,
removeAttachment,
messageCompositionId,
sendMultiMediaMessage,
setComposerFocus,
theme,
// AttachmentList
@ -209,7 +203,6 @@ export function CompositionArea({
onSelectMediaQuality,
shouldSendHighQualityAttachments,
// CompositionInput
compositionApi,
onEditorStateChange,
onTextTooLong,
draftText,
@ -315,11 +308,22 @@ export function CompositionArea({
const attachFileShortcut = useAttachFileShortcut(launchAttachmentPicker);
useKeyboardShortcuts(attachFileShortcut);
const focusInput = useCallback(() => {
// Focus input on first mount
const previousFocusCounter = usePrevious<number | undefined>(
focusCounter,
focusCounter
);
useEffect(() => {
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
});
// Focus input whenever explicitly requested
useEffect(() => {
if (focusCounter !== previousFocusCounter && inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef, focusCounter, previousFocusCounter]);
const withStickers =
countStickers({
@ -329,20 +333,6 @@ export function CompositionArea({
receivedPacks,
}) > 0;
if (compositionApi) {
// Using a React.MutableRefObject, so we need to reassign this prop.
// eslint-disable-next-line no-param-reassign
compositionApi.current = {
isDirty: () => dirty,
focusInput,
resetEmojiResults: () => {
if (inputApiRef.current) {
inputApiRef.current.resetEmojiResults();
}
},
};
}
const previousMessageCompositionId = usePrevious(
messageCompositionId,
messageCompositionId
@ -382,7 +372,7 @@ export function CompositionArea({
i18n={i18n}
doSend={handleForceSend}
onPickEmoji={insertEmoji}
onClose={focusInput}
onClose={() => setComposerFocus(conversationId)}
recentEmojis={recentEmojis}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
@ -706,6 +696,7 @@ export function CompositionArea({
)}
>
<CompositionInput
conversationId={conversationId}
clearQuotedMessage={clearQuotedMessage}
disabled={isDisabled}
draftBodyRanges={draftBodyRanges}

View file

@ -62,12 +62,12 @@ export type InputApi = {
insertEmoji: (e: EmojiPickDataType) => void;
setText: (text: string, cursorToEnd?: boolean) => void;
reset: () => void;
resetEmojiResults: () => void;
submit: () => void;
};
export type Props = Readonly<{
children?: React.ReactNode;
conversationId?: string;
i18n: LocalizerType;
disabled?: boolean;
getPreferredBadge: PreferredBadgeSelectorType;
@ -83,6 +83,7 @@ export type Props = Readonly<{
scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(
conversationId: string | undefined,
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
@ -105,6 +106,7 @@ const BASE_CLASS_NAME = 'module-composition-input';
export function CompositionInput(props: Props): React.ReactElement {
const {
children,
conversationId,
i18n,
disabled,
large,
@ -246,16 +248,6 @@ export function CompositionInput(props: Props): React.ReactElement {
}
};
const resetEmojiResults = () => {
const emojiCompletion = emojiCompletionRef.current;
if (emojiCompletion === undefined) {
return;
}
emojiCompletion.reset();
};
const submit = () => {
const timestamp = Date.now();
const quill = quillRef.current;
@ -286,7 +278,6 @@ export function CompositionInput(props: Props): React.ReactElement {
insertEmoji,
setText,
reset,
resetEmojiResults,
submit,
};
}
@ -446,6 +437,7 @@ export function CompositionInput(props: Props): React.ReactElement {
const selection = quill.getSelection();
onEditorStateChange(
conversationId,
text,
mentions,
selection ? selection.index : undefined

View file

@ -87,6 +87,7 @@ export function CompositionTextArea({
const handleChange = React.useCallback(
(
_conversationId: string | undefined,
newValue: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number | undefined

View file

@ -56,6 +56,7 @@ export type DataPropsType = {
messageBody?: string;
onClose: () => void;
onEditorStateChange: (
conversationId: string | undefined,
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
@ -332,7 +333,12 @@ export function ForwardMessageModal({
draftText={messageBodyText}
onChange={(messageText, bodyRanges, caretLocation?) => {
setMessageBodyText(messageText);
onEditorStateChange(messageText, bodyRanges, caretLocation);
onEditorStateChange(
undefined,
messageText,
bodyRanges,
caretLocation
);
}}
onSubmit={forwardMessage}
theme={theme}

View file

@ -245,7 +245,7 @@ export function StoryViewsNRepliesModal({
i18n={i18n}
inputApi={inputApiRef}
moduleClassName="StoryViewsNRepliesModal__input"
onEditorStateChange={messageText => {
onEditorStateChange={(_conversationId, messageText) => {
setMessageBodyText(messageText);
}}
onPickEmoji={onUseEmoji}

View file

@ -3,6 +3,7 @@
import path from 'path';
import { debounce } from 'lodash';
import type { ThunkAction } from 'redux-thunk';
import type {
@ -40,8 +41,9 @@ import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUnti
import { clearConversationDraftAttachments } from '../../util/clearConversationDraftAttachments';
import { deleteDraftAttachment } from '../../util/deleteDraftAttachment';
import {
hasLinkPreviewLoaded,
getLinkPreviewForSend,
hasLinkPreviewLoaded,
maybeGrabLinkPreview,
resetLinkPreview,
} from '../../services/LinkPreview';
import { getMaximumAttachmentSize } from '../../util/attachments';
@ -64,6 +66,7 @@ import { writeDraftAttachment } from '../../util/writeDraftAttachment';
export type ComposerStateType = {
attachments: ReadonlyArray<AttachmentDraftType>;
focusCounter: number;
isDisabled: boolean;
linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewType;
@ -77,6 +80,7 @@ export type ComposerStateType = {
const ADD_PENDING_ATTACHMENT = 'composer/ADD_PENDING_ATTACHMENT';
const REPLACE_ATTACHMENTS = 'composer/REPLACE_ATTACHMENTS';
const RESET_COMPOSER = 'composer/RESET_COMPOSER';
const SET_FOCUS = 'composer/SET_FOCUS';
const SET_HIGH_QUALITY_SETTING = 'composer/SET_HIGH_QUALITY_SETTING';
const SET_QUOTED_MESSAGE = 'composer/SET_QUOTED_MESSAGE';
const SET_COMPOSER_DISABLED = 'composer/SET_COMPOSER_DISABLED';
@ -100,6 +104,10 @@ type SetComposerDisabledStateActionType = {
payload: boolean;
};
type SetFocusActionType = {
type: typeof SET_FOCUS;
};
type SetHighQualitySettingActionType = {
type: typeof SET_HIGH_QUALITY_SETTING;
payload: boolean;
@ -117,6 +125,7 @@ type ComposerActionType =
| ReplaceAttachmentsActionType
| ResetComposerActionType
| SetComposerDisabledStateActionType
| SetFocusActionType
| SetHighQualitySettingActionType
| SetQuotedMessageActionType;
@ -125,13 +134,15 @@ type ComposerActionType =
export const actions = {
addAttachment,
addPendingAttachment,
onEditorStateChange,
processAttachments,
removeAttachment,
replaceAttachments,
resetComposer,
setComposerDisabledState,
sendMultiMediaMessage,
sendStickerMessage,
setComposerDisabledState,
setComposerFocus,
setMediaQualitySetting,
setQuotedMessage,
};
@ -421,6 +432,56 @@ function addPendingAttachment(
};
}
function setComposerFocus(
conversationId: string
): ThunkAction<void, RootStateType, unknown, SetFocusActionType> {
return async (dispatch, getState) => {
if (getState().conversations.selectedConversationId !== conversationId) {
return;
}
dispatch({
type: SET_FOCUS,
});
};
}
function onEditorStateChange(
conversationId: string | undefined,
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
): NoopActionType {
if (!conversationId) {
throw new Error(
'onEditorStateChange: Got falsey conversationId, needs local override'
);
}
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('processAttachments: Unable to find conversation');
}
if (messageText.length && conversation.throttledBumpTyping) {
conversation.throttledBumpTyping();
}
debouncedSaveDraft(conversationId, messageText, bodyRanges);
// If we have attachments, don't add link preview
if (!hasDraftAttachments(conversation.attributes, { includePending: true })) {
maybeGrabLinkPreview(messageText, LinkPreviewSourceType.Composer, {
caretLocation,
});
}
return {
type: 'NOOP',
payload: null,
};
}
function processAttachments({
conversationId,
files,
@ -661,6 +722,51 @@ function resetComposer(): ResetComposerActionType {
type: RESET_COMPOSER,
};
}
const debouncedSaveDraft = debounce(saveDraft);
function saveDraft(
conversationId: string,
messageText: string,
bodyRanges: DraftBodyRangesType
) {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error('saveDraft: Unable to find conversation');
}
const trimmed =
messageText && messageText.length > 0 ? messageText.trim() : '';
if (conversation.get('draft') && (!messageText || trimmed.length === 0)) {
conversation.set({
draft: null,
draftChanged: true,
draftBodyRanges: [],
});
window.Signal.Data.updateConversation(conversation.attributes);
return;
}
if (messageText !== conversation.get('draft')) {
const now = Date.now();
let activeAt = conversation.get('active_at');
let timestamp = conversation.get('timestamp');
if (!activeAt) {
activeAt = now;
timestamp = now;
}
conversation.set({
active_at: activeAt,
draft: messageText,
draftBodyRanges: bodyRanges,
draftChanged: true,
timestamp,
});
window.Signal.Data.updateConversation(conversation.attributes);
}
}
function setComposerDisabledState(
value: boolean
@ -694,6 +800,7 @@ function setQuotedMessage(
export function getEmptyState(): ComposerStateType {
return {
attachments: [],
focusCounter: 0,
isDisabled: false,
linkPreviewLoading: false,
messageCompositionId: UUID.generate().toString(),
@ -719,6 +826,13 @@ export function reducer(
};
}
if (action.type === SET_FOCUS) {
return {
...state,
focusCounter: state.focusCounter + 1,
};
}
if (action.type === SET_HIGH_QUALITY_SETTING) {
return {
...state,

View file

@ -70,6 +70,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const {
attachments: draftAttachments,
focusCounter,
isDisabled,
linkPreviewLoading,
linkPreviewResult,
@ -83,11 +84,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return {
// Base
conversationId: id,
focusCounter,
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
isDisabled,
messageCompositionId,
theme: getTheme(state),
// AudioCapture
errorDialogAudioRecorderType:
state.audioRecorder.errorDialogAudioRecorderType,

View file

@ -18,7 +18,6 @@ export type PropsType = {
compositionAreaProps: Pick<
CompositionAreaPropsType,
| 'clearQuotedMessage'
| 'compositionApi'
| 'getQuotedMessage'
| 'handleClickQuotedMessage'
| 'id'

View file

@ -121,6 +121,7 @@ export function SmartForwardMessageModal(): JSX.Element | null {
messageBody={cleanedBody}
onClose={closeModal}
onEditorStateChange={(
_conversationId: string | undefined,
messageText: string,
_: DraftBodyRangesType,
caretLocation?: number

View file

@ -107,6 +107,7 @@ describe('both/state/ducks/composer', () => {
const nextState = reducer(
{
attachments: [],
focusCounter: 0,
isDisabled: false,
linkPreviewLoading: true,
messageCompositionId: emptyState.messageCompositionId,

View file

@ -6,13 +6,12 @@
import type * as Backbone from 'backbone';
import type { ComponentProps } from 'react';
import * as React from 'react';
import { debounce, flatten } from 'lodash';
import { flatten } from 'lodash';
import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment';
import { isGIF } from '../types/Attachment';
import * as Stickers from '../types/Stickers';
import type { DraftBodyRangesType } from '../types/Util';
import type { MIMEType } from '../types/MIME';
import type { ConversationModel } from '../models/conversations';
import type { MessageAttributesType } from '../model-types.d';
@ -43,7 +42,6 @@ import { ConversationDetailsMembershipList } from '../components/conversation/co
import * as log from '../logging/log';
import type { EmbeddedContactType } from '../types/EmbeddedContact';
import { createConversationView } from '../state/roots/createConversationView';
import type { CompositionAPIType } from '../components/CompositionArea';
import { ToastConversationArchived } from '../components/ToastConversationArchived';
import { ToastConversationMarkedUnread } from '../components/ToastConversationMarkedUnread';
import { ToastConversationUnarchived } from '../components/ToastConversationUnarchived';
@ -67,16 +65,15 @@ import { ContactDetail } from '../components/conversation/ContactDetail';
import { MediaGallery } from '../components/conversation/media-gallery/MediaGallery';
import type { ItemClickEvent } from '../components/conversation/media-gallery/types/ItemClickEvent';
import {
maybeGrabLinkPreview,
removeLinkPreview,
suspendLinkPreviews,
} from '../services/LinkPreview';
import { LinkPreviewSourceType } from '../types/LinkPreview';
import { closeLightbox, showLightbox } from '../util/showLightbox';
import { saveAttachment } from '../util/saveAttachment';
import { SECOND } from '../util/durations';
import { startConversation } from '../util/startConversation';
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
import { hasDraftAttachments } from '../util/hasDraftAttachments';
type AttachmentOptions = {
messageId: string;
@ -160,16 +157,6 @@ type MediaType = {
};
export class ConversationView extends window.Backbone.View<ConversationModel> {
private debouncedSaveDraft: (
messageText: string,
bodyRanges: DraftBodyRangesType
) => Promise<void>;
// Composing messages
private compositionApi: {
current: CompositionAPIType;
} = { current: undefined };
// Sub-views
private contactModalView?: Backbone.View;
private conversationView?: Backbone.View;
@ -184,8 +171,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
constructor(...args: Array<any>) {
super(...args);
this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200);
// Events on Conversation model
this.listenTo(this.model, 'destroy', this.stopListening);
@ -197,7 +182,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
);
// These are triggered by background.ts for keyboard handling
this.listenTo(this.model, 'focus-composer', this.focusMessageField);
this.listenTo(this.model, 'open-all-media', this.showAllMedia);
this.listenTo(this.model, 'escape-pressed', this.resetPanel);
this.listenTo(this.model, 'show-message-details', this.showMessageDetail);
@ -416,13 +400,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
const compositionAreaProps = {
id: this.model.id,
compositionApi: this.compositionApi,
onClickAddPack: () => this.showStickerManager(),
onEditorStateChange: (
msg: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
) => this.onEditorStateChange(msg, bodyRanges, caretLocation),
onTextTooLong: () => showToast(ToastMessageBodyTooLong),
getQuotedMessage: () => this.model.get('quotedMessageId'),
clearQuotedMessage: () => this.setQuoteMessage(undefined),
@ -736,7 +714,7 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
loadAndUpdate();
this.focusMessageField();
window.reduxActions.composer.setComposerFocus(this.model.id);
const quotedMessageId = this.model.get('quotedMessageId');
if (quotedMessageId) {
@ -1619,78 +1597,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
);
}
focusMessageField(): void {
if (this.panels && this.panels.length) {
return;
}
this.compositionApi.current?.focusInput();
}
resetEmojiResults(): void {
this.compositionApi.current?.resetEmojiResults();
}
onEditorStateChange(
messageText: string,
bodyRanges: DraftBodyRangesType,
caretLocation?: number
): void {
if (messageText.length && this.model.throttledBumpTyping) {
this.model.throttledBumpTyping();
}
this.debouncedSaveDraft(messageText, bodyRanges);
// If we have attachments, don't add link preview
if (!this.hasFiles({ includePending: true })) {
maybeGrabLinkPreview(messageText, LinkPreviewSourceType.Composer, {
caretLocation,
});
}
}
async saveDraft(
messageText: string,
bodyRanges: DraftBodyRangesType
): Promise<void> {
const { model }: { model: ConversationModel } = this;
const trimmed =
messageText && messageText.length > 0 ? messageText.trim() : '';
if (model.get('draft') && (!messageText || trimmed.length === 0)) {
this.model.set({
draft: null,
draftChanged: true,
draftBodyRanges: [],
});
await this.saveModel();
return;
}
if (messageText !== model.get('draft')) {
const now = Date.now();
let active_at = this.model.get('active_at');
let timestamp = this.model.get('timestamp');
if (!active_at) {
active_at = now;
timestamp = now;
}
this.model.set({
active_at,
draft: messageText,
draftBodyRanges: bodyRanges,
draftChanged: true,
timestamp,
});
await this.saveModel();
}
}
async setQuoteMessage(messageId: string | undefined): Promise<void> {
const { model } = this;
const message = messageId ? await getMessageById(messageId) : undefined;
@ -1738,8 +1644,8 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
quote,
});
window.reduxActions.composer.setComposerFocus(this.model.id);
window.reduxActions.composer.setComposerDisabledState(false);
this.focusMessageField();
} else {
window.reduxActions.composer.setQuotedMessage(undefined);
}
@ -1763,22 +1669,13 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
]);
}
hasFiles(options: { includePending: boolean }): boolean {
const draftAttachments = this.model.get('draftAttachments') || [];
if (options.includePending) {
return draftAttachments.length > 0;
}
return draftAttachments.some(item => !item.pending);
}
updateAttachmentsView(): void {
const draftAttachments = this.model.get('draftAttachments') || [];
window.reduxActions.composer.replaceAttachments(
this.model.get('id'),
draftAttachments
);
if (this.hasFiles({ includePending: true })) {
if (hasDraftAttachments(this.model.attributes, { includePending: true })) {
removeLinkPreview();
}
}