signal-desktop/ts/components/CompositionTextArea.tsx

239 lines
7.3 KiB
TypeScript
Raw Normal View History

2022-10-04 17:17:15 -06:00
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useCallback, useState } from 'react';
2022-10-04 17:17:15 -06:00
import type { LocalizerType } from '../types/I18N';
import type { EmojiPickDataType } from './emoji/EmojiPicker';
import type { InputApi } from './CompositionInput';
import { CompositionInput } from './CompositionInput';
import { EmojiButton } from './emoji/EmojiButton';
2025-04-29 13:27:33 -07:00
import {
hydrateRanges,
type DraftBodyRanges,
type HydratedBodyRangesType,
} from '../types/BodyRange';
import type { ThemeType } from '../types/Util';
2022-10-04 17:17:15 -06:00
import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
import { FunEmojiPicker } from './fun/FunEmojiPicker';
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
2025-03-26 12:35:32 -07:00
import type { EmojiSkinTone } from './fun/data/emojis';
import { FunEmojiPickerButton } from './fun/FunButton';
import { isFunPickerEnabled } from './fun/isFunPickerEnabled';
2025-04-29 13:27:33 -07:00
import type { GetConversationByIdType } from '../state/selectors/conversations';
2022-10-04 17:17:15 -06:00
export type CompositionTextAreaProps = {
2024-03-12 09:29:31 -07:00
bodyRanges: HydratedBodyRangesType | null;
2022-10-04 17:17:15 -06:00
i18n: LocalizerType;
isActive: boolean;
isFormattingEnabled: boolean;
2022-10-04 17:17:15 -06:00
maxLength?: number;
placeholder?: string;
whenToShowRemainingCount?: number;
onScroll?: (ev: React.UIEvent<HTMLElement, UIEvent>) => void;
onPickEmoji: (e: EmojiPickDataType) => void;
onChange: (
messageText: string,
draftBodyRanges: HydratedBodyRangesType,
2022-10-04 17:17:15 -06:00
caretLocation?: number | undefined
) => void;
2025-03-26 12:35:32 -07:00
onEmojiSkinToneDefaultChange: (emojiSkinToneDefault: EmojiSkinTone) => void;
2022-10-04 17:17:15 -06:00
onSubmit: (
message: string,
draftBodyRanges: DraftBodyRanges,
2022-10-04 17:17:15 -06:00
timestamp: number
) => void;
onTextTooLong: () => void;
2024-12-20 10:33:01 -08:00
ourConversationId: string | undefined;
platform: string;
2022-10-04 17:17:15 -06:00
getPreferredBadge: PreferredBadgeSelectorType;
draftText: string;
theme: ThemeType;
2025-04-29 13:27:33 -07:00
conversationSelector: GetConversationByIdType;
2025-03-26 12:35:32 -07:00
} & Pick<EmojiButtonProps, 'recentEmojis' | 'emojiSkinToneDefault'>;
2022-10-04 17:17:15 -06:00
/**
* Essentially an HTML textarea but with support for emoji picker and
* at-mentions autocomplete.
*
* Meant for modals that need to collect a message or caption. It is
* basically a rectangle input with an emoji selector floating at the top-right
*/
2022-11-17 16:45:19 -08:00
export function CompositionTextArea({
bodyRanges,
draftText,
getPreferredBadge,
2022-10-04 17:17:15 -06:00
i18n,
isActive,
isFormattingEnabled,
2022-10-04 17:17:15 -06:00
maxLength,
onChange,
onPickEmoji,
onScroll,
2025-03-26 12:35:32 -07:00
onEmojiSkinToneDefaultChange,
2022-10-04 17:17:15 -06:00
onSubmit,
onTextTooLong,
2024-12-20 10:33:01 -08:00
ourConversationId,
placeholder,
platform,
2022-10-04 17:17:15 -06:00
recentEmojis,
2025-03-26 12:35:32 -07:00
emojiSkinToneDefault,
theme,
whenToShowRemainingCount = Infinity,
2025-04-29 13:27:33 -07:00
conversationSelector,
2022-11-17 16:45:19 -08:00
}: CompositionTextAreaProps): JSX.Element {
const inputApiRef = useRef<InputApi | undefined>();
const [characterCount, setCharacterCount] = useState(
2022-10-04 17:17:15 -06:00
grapheme.count(draftText)
);
const insertEmoji = useCallback(
2022-10-04 17:17:15 -06:00
(e: EmojiPickDataType) => {
if (inputApiRef.current) {
inputApiRef.current.insertEmoji(e);
onPickEmoji(e);
}
},
[inputApiRef, onPickEmoji]
);
const handleSelectEmoji = useCallback(
(emojiSelection: FunEmojiSelection) => {
const data: EmojiPickDataType = {
shortName: emojiSelection.englishShortName,
skinTone: emojiSelection.skinTone,
};
insertEmoji(data);
},
[insertEmoji]
);
const focusTextEditInput = useCallback(() => {
2022-10-04 17:17:15 -06:00
if (inputApiRef.current) {
inputApiRef.current.focus();
}
}, [inputApiRef]);
const [emojiPickerOpen, setEmojiPickerOpen] = useState(false);
const handleEmojiPickerOpenChange = useCallback(
(open: boolean) => {
setEmojiPickerOpen(open);
if (!open) {
focusTextEditInput();
}
},
[focusTextEditInput]
);
const handleChange = useCallback(
({
bodyRanges: updatedBodyRanges,
caretLocation,
messageText: newValue,
2025-04-29 13:27:33 -07:00
}: {
bodyRanges: DraftBodyRanges;
caretLocation?: number | undefined;
messageText: string;
}) => {
2022-10-04 17:17:15 -06:00
const inputEl = inputApiRef.current;
if (!inputEl) {
return;
}
const [newValueSized, newCharacterCount] = grapheme.truncateAndSize(
newValue,
maxLength
);
2025-04-29 13:27:33 -07:00
const hydratedBodyRanges =
hydrateRanges(updatedBodyRanges, conversationSelector) ?? [];
2022-10-04 17:17:15 -06:00
if (maxLength !== undefined) {
// if we had to truncate
if (newValueSized.length < newValue.length) {
// reset quill to the value before the change that pushed it over the max
// and push the cursor to the end
//
// this is not perfect as it pushes the cursor to the end, even if the user
// was modifying text in the middle of the editor
// a better solution would be to prevent the change to begin with, but
// quill makes this VERY difficult
2025-04-29 13:27:33 -07:00
inputEl.setContents(newValueSized, hydratedBodyRanges, true);
2022-10-04 17:17:15 -06:00
}
}
setCharacterCount(newCharacterCount);
2025-04-29 13:27:33 -07:00
onChange(newValue, hydratedBodyRanges, caretLocation);
2022-10-04 17:17:15 -06:00
},
2025-04-29 13:27:33 -07:00
[maxLength, onChange, conversationSelector]
2022-10-04 17:17:15 -06:00
);
return (
<div className="CompositionTextArea">
<CompositionInput
draftBodyRanges={bodyRanges}
draftText={draftText}
2022-10-04 17:17:15 -06:00
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isActive={isActive}
isFormattingEnabled={isFormattingEnabled}
2022-10-04 17:17:15 -06:00
inputApi={inputApiRef}
2025-03-26 17:14:29 -07:00
large={false}
2022-10-04 17:17:15 -06:00
moduleClassName="CompositionTextArea__input"
onEditorStateChange={handleChange}
onPickEmoji={onPickEmoji}
onScroll={onScroll}
2022-10-04 17:17:15 -06:00
onSubmit={onSubmit}
onTextTooLong={onTextTooLong}
2024-12-20 10:33:01 -08:00
ourConversationId={ourConversationId}
placeholder={placeholder}
platform={platform}
quotedMessageId={null}
sendCounter={0}
2022-10-04 17:17:15 -06:00
theme={theme}
2025-03-26 12:35:32 -07:00
emojiSkinToneDefault={emojiSkinToneDefault}
2024-03-12 09:29:31 -07:00
// These do not apply in the forward modal because there isn't
// strictly one conversation
conversationId={null}
sortedGroupMembers={null}
// we don't edit in this context
draftEditMessage={null}
// rendered in the forward modal
linkPreviewResult={null}
// Panels appear behind this modal
shouldHidePopovers={null}
2022-10-04 17:17:15 -06:00
/>
<div className="CompositionTextArea__emoji">
{!isFunPickerEnabled() && (
<EmojiButton
i18n={i18n}
onClose={focusTextEditInput}
onPickEmoji={insertEmoji}
onEmojiSkinToneDefaultChange={onEmojiSkinToneDefaultChange}
recentEmojis={recentEmojis}
emojiSkinToneDefault={emojiSkinToneDefault}
/>
)}
{isFunPickerEnabled() && (
<FunEmojiPicker
placement="bottom"
open={emojiPickerOpen}
onOpenChange={handleEmojiPickerOpenChange}
onSelectEmoji={handleSelectEmoji}
closeOnSelect={false}
>
<FunEmojiPickerButton i18n={i18n} />
</FunEmojiPicker>
)}
2022-10-04 17:17:15 -06:00
</div>
{maxLength !== undefined &&
characterCount >= whenToShowRemainingCount && (
<div className="CompositionTextArea__remaining-character-count">
{maxLength - characterCount}
</div>
)}
</div>
);
2022-11-17 16:45:19 -08:00
}