diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 4de44d2e8..0fbd913fb 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -27,7 +27,8 @@ type Limit = | 'chatlist_joined_limit' | 'recommended_channels_limit' | 'saved_dialogs_pinned_limit' - | 'reactions_user_max'; + | 'reactions_user_max' + | 'aicompose_tone_saved_limit'; type LimitKey = `${Limit}_${LimitType}`; type LimitsConfig = Record; @@ -135,8 +136,6 @@ export interface GramJsAppConfig extends LimitsConfig { aicompose_tone_examples_num?: number; aicompose_tone_title_length_max?: number; aicompose_tone_prompt_length_max?: number; - aicompose_tone_saved_limit_default?: number; - aicompose_tone_saved_limit_premium?: number; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -217,6 +216,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp savedDialogsPinned: getLimit(appConfig, 'saved_dialogs_pinned_limit', 'savedDialogsPinned'), maxReactions: getLimit(appConfig, 'reactions_user_max', 'maxReactions'), moreAccounts: DEFAULT_LIMITS.moreAccounts, + aiComposeToneSaved: getLimit(appConfig, 'aicompose_tone_saved_limit', 'aiComposeToneSaved'), }, contactNoteLimit: appConfig.contact_note_length_limit, hash, @@ -284,8 +284,6 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp aiComposeToneExamplesNum: appConfig.aicompose_tone_examples_num, aiComposeToneTitleLengthMax: appConfig.aicompose_tone_title_length_max, aiComposeTonePromptLengthMax: appConfig.aicompose_tone_prompt_length_max, - aiComposeToneSavedLimitDefault: appConfig.aicompose_tone_saved_limit_default, - aiComposeToneSavedLimitPremium: appConfig.aicompose_tone_saved_limit_premium, }; return { diff --git a/src/api/gramjs/apiBuilders/messageContent.ts b/src/api/gramjs/apiBuilders/messageContent.ts index b4874667c..93fd70bc6 100644 --- a/src/api/gramjs/apiBuilders/messageContent.ts +++ b/src/api/gramjs/apiBuilders/messageContent.ts @@ -1004,6 +1004,10 @@ export function buildWebPage(webPage: GramJs.TypeWebPage): ApiWebPage | undefine }; } + const attributeAiTone = attributes?.find((a): a is GramJs.WebPageAttributeAiComposeTone => ( + a instanceof GramJs.WebPageAttributeAiComposeTone + )); + return { mediaType: 'webpage', webpageType: 'full', @@ -1026,6 +1030,7 @@ export function buildWebPage(webPage: GramJs.TypeWebPage): ApiWebPage | undefine gift, auction, stickers, + aiComposeToneEmojiId: attributeAiTone?.emojiId.toString(), }; } diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index a94444898..2f74beb44 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -64,7 +64,9 @@ import { buildApiSponsoredMessageReportResult, buildThreadReadState, } from '../apiBuilders/chats'; -import { buildApiAiComposeTone, buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common'; +import { + buildApiAiComposeTone, buildApiAiComposeToneExample, buildApiComposedMessageWithAI, buildApiFormattedText, +} from '../apiBuilders/common'; import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent, @@ -2853,3 +2855,110 @@ export async function fetchAiComposeTones({ hash: result.hash.toString(), }; } + +export async function createAiTone({ + title, + emojiId, + prompt, + shouldDisplayAuthor, +}: { + title: string; + emojiId: string; + prompt: string; + shouldDisplayAuthor?: boolean; +}) { + const result = await invokeRequest(new GramJs.aicompose.CreateTone({ + title, + prompt, + emojiId: BigInt(emojiId), + displayAuthor: shouldDisplayAuthor || undefined, + })); + + if (!result) return undefined; + + return buildApiAiComposeTone(result); +} + +export async function deleteAiTone({ + tone, +}: { + tone: ApiInputAiComposeTone; +}) { + return invokeRequest(new GramJs.aicompose.DeleteTone({ + tone: buildInputAiComposeTone(tone), + })); +} + +export async function updateAiTone({ + tone, + title, + emojiId, + prompt, + shouldDisplayAuthor, +}: { + tone: ApiInputAiComposeTone; + title?: string; + emojiId?: string; + prompt?: string; + shouldDisplayAuthor?: boolean; +}) { + const result = await invokeRequest(new GramJs.aicompose.UpdateTone({ + tone: buildInputAiComposeTone(tone), + title, + prompt, + emojiId: emojiId ? BigInt(emojiId) : undefined, + displayAuthor: shouldDisplayAuthor, + })); + + if (!result) return undefined; + + return buildApiAiComposeTone(result); +} + +export async function fetchAiTone({ + tone, +}: { + tone: ApiInputAiComposeTone; +}) { + const result = await invokeRequest(new GramJs.aicompose.GetTone({ + tone: buildInputAiComposeTone(tone), + })); + + if (!result || !('tones' in result)) { + return undefined; + } + + return { + tones: result.tones.map(buildApiAiComposeTone), + }; +} + +export async function fetchAiToneExample({ + tone, + num, +}: { + tone: ApiInputAiComposeTone; + num: number; +}) { + const result = await invokeRequest(new GramJs.aicompose.GetToneExample({ + tone: buildInputAiComposeTone(tone), + num, + })); + + if (!result) return undefined; + + return buildApiAiComposeToneExample(result); +} + +export async function saveAiTone({ + tone, + unsave, +}: { + tone: ApiInputAiComposeTone; + unsave?: boolean; +}) { + return invokeRequest(new GramJs.aicompose.SaveTone({ + tone: buildInputAiComposeTone(tone), + unsave: Boolean(unsave), + })); +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index c92f4b031..5cc0116ca 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -426,6 +426,7 @@ export interface ApiWebPageFull { gift?: ApiStarGiftUnique; auction?: ApiWebPageAuctionData; stickers?: ApiWebPageStickerData; + aiComposeToneEmojiId?: string; hasLargeMedia?: boolean; } diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 47ac03d65..877708b59 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -337,8 +337,6 @@ export interface ApiAppConfig { aiComposeToneExamplesNum?: number; aiComposeToneTitleLengthMax?: number; aiComposeTonePromptLengthMax?: number; - aiComposeToneSavedLimitDefault?: number; - aiComposeToneSavedLimitPremium?: number; } export interface ApiConfig { @@ -464,15 +462,17 @@ export type ApiLimitType = | 'recommendedChannels' | 'savedDialogsPinned' | 'maxReactions' - | 'moreAccounts'; + | 'moreAccounts' + | 'aiComposeToneSaved'; export type ApiLimitTypeWithModal = Exclude; export type ApiLimitTypeForPromo = Exclude; export type ApiPeerNotifySettings = { diff --git a/src/assets/font-icons/reload-arrows.svg b/src/assets/font-icons/reload-arrows.svg new file mode 100644 index 000000000..ccdd5c546 --- /dev/null +++ b/src/assets/font-icons/reload-arrows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index a9cc37329..c69185d17 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1910,6 +1910,7 @@ "ViewButtonStickerset" = "VIEW STICKERS"; "ViewButtonEmojiset" = "VIEW EMOJI"; "ViewButtonGiftUnique" = "VIEW COLLECTIBLE"; +"ViewButtonAiStyle" = "VIEW STYLE"; "AuthContinueOnThisLanguage" = "Continue in English"; "Share" = "Share"; "GiftSortByDate" = "Sort by Date"; @@ -2889,6 +2890,32 @@ "AiMessageEditorApply" = "Apply"; "AiMessageEditorEmojify" = "emojify"; "AiMessageEditorTranslation" = "Translation"; +"AiToneEditorNewStyle" = "New Style"; +"AiToneEditorTitle" = "Create AI Tone"; +"AiToneEditorNamePlaceholder" = "Style Name (for example: Pirate)"; +"AiToneEditorPromptPlaceholder" = "Instructions (for example write in bold, nautical tone, light slang, vivid sea imagery, playful swagger)"; +"AiToneEditorDisplayAuthor" = "Add a link to my account"; +"AiToneEditorSelectEmoji" = "Select Emoji"; +"AiToneCreated" = "{title} style created!"; +"AiToneCreatedHint" = "Press and hold a style to edit or share it."; +"AiToneEditStyle" = "Edit Style"; +"AiToneShareStyle" = "Share Style"; +"AiToneDeleteStyle" = "Delete Style"; +"AiToneDeleteStyleConfirmOwn" = "Are you sure you want to delete this style? It will be removed for everyone who installed it."; +"AiToneDeleteStyleConfirm" = "Are you sure you want to remove this style?"; +"AiToneEditorEditTitle" = "Edit AI Style"; +"AiTonePreviewSubtitle" = "Add this style to instantly rewrite your messages."; +"AiTonePreviewBefore" = "Before"; +"AiTonePreviewAnotherExample" = "Another Example"; +"AiTonePreviewAfter" = "After"; +"AiTonePreviewAddStyle" = "Add Style"; +"AiTonePreviewRemoveStyle" = "Remove Style"; +"AiTonePreviewStyleAdded" = "Style added"; +"AiToneLimitReached" = "You have reached the limit of custom styles."; +"AiToneLimitReachedPremium" = "You have reached the limit of **{limit}** custom styles."; +"AiTonePreviewUsedBy" = "Used by {count} people."; +"AiTonePreviewCreatedBy" = "Created by {author}."; +"AiTonePreviewUsedByCreatedBy" = "{usedBy} {createdBy}"; "TextShowMore" = "more"; "TextShowLess" = "less"; "AiMessageEditorFrom" = "From"; diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 0614c080a..92a04fe98 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -73,6 +73,10 @@ export { default as ReactionPicker } from '../components/middle/message/reaction export { default as AiMessageEditorModal } from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal'; +export { default as AiToneEmojiPickerModal } + from '../components/middle/composer/AiMessageEditorModal/AiToneEmojiPickerModal'; +export { default as AiTonePreviewModal } + from '../components/modals/aiTonePreview/AiTonePreviewModal'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as PollModal } from '../components/modals/poll/PollModal'; diff --git a/src/components/common/CustomEmojiPicker.tsx b/src/components/common/CustomEmojiPicker.tsx index 89dfdb6c7..3faaeff04 100644 --- a/src/components/common/CustomEmojiPicker.tsx +++ b/src/components/common/CustomEmojiPicker.tsx @@ -68,6 +68,7 @@ type OwnProps = { isStatusPicker?: boolean; isReactionPicker?: boolean; isTranslucent?: boolean; + noAddButton?: boolean; onCustomEmojiSelect: (sticker: ApiSticker) => void; onReactionSelect?: (reaction: ApiReactionWithPaid) => void; onReactionContext?: (reaction: ApiReactionWithPaid) => void; @@ -130,6 +131,7 @@ const CustomEmojiPicker: FC = ({ isReactionPicker, isStatusPicker, isTranslucent, + noAddButton, isSavedMessages, isCurrentUserPremium, withDefaultTopicIcons, @@ -451,6 +453,7 @@ const CustomEmojiPicker: FC = ({ isSavedMessages={isSavedMessages} isStatusPicker={isStatusPicker} isReactionPicker={isReactionPicker} + noAddButton={noAddButton} shouldHideHeader={shouldHideHeader} withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID} withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID} diff --git a/src/components/common/StickerSet.tsx b/src/components/common/StickerSet.tsx index e1b703e8d..b258d2dc0 100644 --- a/src/components/common/StickerSet.tsx +++ b/src/components/common/StickerSet.tsx @@ -62,6 +62,7 @@ type OwnProps = { isChatStickerSet?: boolean; isTranslucent?: boolean; noContextMenus?: boolean; + noAddButton?: boolean; forcePlayback?: boolean; observeIntersection?: ObserveFn; observeIntersectionForPlayingItems: ObserveFn; @@ -106,6 +107,7 @@ const StickerSet = ({ isChatStickerSet, isTranslucent, noContextMenus, + noAddButton, forcePlayback, collectibleStatuses, observeIntersection, @@ -260,7 +262,7 @@ const StickerSet = ({ const collectibleEmojiIdsSet = useMemo(() => ( collectibleStatuses ? new Set(collectibleStatuses.map(({ documentId }) => documentId)) : undefined ), [collectibleStatuses]); - const withAddSetButton = !shouldHideHeader && !isRecent && !isStatusCollectible + const withAddSetButton = !noAddButton && !shouldHideHeader && !isRecent && !isStatusCollectible && isEmoji && !isPopular && !isChatEmojiSet && (!isInstalled || (!isCurrentUserPremium && !isSavedMessages)); const addSetButtonText = useMemo(() => { diff --git a/src/components/gili/modal/Modal.module.scss b/src/components/gili/modal/Modal.module.scss index df7a0def0..b81e92f05 100644 --- a/src/components/gili/modal/Modal.module.scss +++ b/src/components/gili/modal/Modal.module.scss @@ -143,6 +143,11 @@ background-color: transparent; } + .stickyFooter { + position: relative; + flex-shrink: 0; + } + .withHeader { mask-image: linear-gradient( diff --git a/src/components/gili/modal/Modal.tsx b/src/components/gili/modal/Modal.tsx index c1d3a2a39..3fcba5cee 100644 --- a/src/components/gili/modal/Modal.tsx +++ b/src/components/gili/modal/Modal.tsx @@ -43,6 +43,10 @@ export type ModalProps = { height?: ModalHeight; noBackdrop?: boolean; noLightDismiss?: boolean; + noScrollable?: boolean; + noContentInlinePadding?: boolean; + keepMounted?: boolean; + stickyFooter?: TeactNode; ariaLabel?: string; noContainment?: boolean; onClose: NoneToVoidFunction; @@ -114,10 +118,15 @@ const Modal = ({ height = 'regular', noBackdrop, noLightDismiss, + noScrollable, + noContentInlinePadding, + keepMounted, + stickyFooter, ariaLabel, noContainment, onClose, }: ModalProps) => { + const [hasEverOpened, setHasEverOpened] = useState(Boolean(isOpen)); const [shouldRender, setShouldRender] = useState(Boolean(isOpen)); const [isClosing, setIsClosing] = useState(false); const [hasTitle, setHasTitle] = useState(false); @@ -134,11 +143,14 @@ const Modal = ({ const frozenProps = useFrozenProps({ header, children, + stickyFooter, dialogClassName, contentClassName, width, height, noBackdrop, + noScrollable, + noContentInlinePadding, ariaLabel, noContainment, }, !isOpen); @@ -193,6 +205,12 @@ const Modal = ({ titleId, ]); + useEffect(() => { + if (isOpen && !hasEverOpened) { + setHasEverOpened(true); + } + }, [hasEverOpened, isOpen]); + useEffect(() => { if (isOpen) { cleanupCloseAnimation(); @@ -322,7 +340,7 @@ const Modal = ({ handleRequestClose(); }); - if (!shouldRender) { + if (!shouldRender && !(keepMounted && hasEverOpened)) { return undefined; } @@ -355,7 +373,8 @@ const Modal = ({ )} + {Boolean(frozenProps.stickyFooter) && ( +
+ {frozenProps.stickyFooter} +
+ )} diff --git a/src/components/gili/templates/CheckboxField.module.scss b/src/components/gili/templates/CheckboxField.module.scss new file mode 100644 index 000000000..64a5e6789 --- /dev/null +++ b/src/components/gili/templates/CheckboxField.module.scss @@ -0,0 +1,9 @@ +@layer ui.templates { + .centered { + justify-content: center; + } + + .centeredControl { + flex-grow: 0; + } +} diff --git a/src/components/gili/templates/CheckboxField.tsx b/src/components/gili/templates/CheckboxField.tsx index 8698a09f0..e225b6d12 100644 --- a/src/components/gili/templates/CheckboxField.tsx +++ b/src/components/gili/templates/CheckboxField.tsx @@ -1,5 +1,7 @@ import { memo } from '../../../lib/teact/teact'; +import buildClassName from '../../../util/buildClassName'; + import Control, { ControlDescription, ControlLabel, @@ -7,11 +9,14 @@ import Control, { import Interactive from '../layout/Interactive'; import Checkbox from '../primitives/Checkbox'; +import styles from './CheckboxField.module.scss'; + type Props = Omit, 'className' | 'disabled'> & { label: string; description?: string; disabled?: boolean; loading?: boolean; + isCentered?: boolean; className?: string; controlClassName?: string; labelClassName?: string; @@ -24,6 +29,7 @@ const CheckboxField = ({ description, disabled, loading, + isCentered, className, controlClassName, labelClassName, @@ -38,9 +44,9 @@ const CheckboxField = ({ ripple={ripple} disabled={disabled} loading={loading} - className={className} + className={buildClassName(className, isCentered && styles.centered)} > - + {label} {description !== undefined ? ( diff --git a/src/components/main/Dialogs.tsx b/src/components/main/Dialogs.tsx index 90e6cc99b..27450e343 100644 --- a/src/components/main/Dialogs.tsx +++ b/src/components/main/Dialogs.tsx @@ -64,6 +64,7 @@ const Dialogs = ({ dialogs, currentMessageList }: StateProps) => { onClose={closeModal} className="confirm" title={lang('ShareYouPhoneNumberTitle')} + isNativeDialog onCloseAnimationEnd={dismissDialog} > {lang( @@ -94,6 +95,7 @@ const Dialogs = ({ dialogs, currentMessageList }: StateProps) => { onCloseAnimationEnd={dismissDialog} className="error" title={title} + isNativeDialog > {renderedText}
diff --git a/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.module.scss b/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.module.scss index 50a95af7c..63be60f1e 100644 --- a/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.module.scss +++ b/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.module.scss @@ -32,7 +32,9 @@ .resultArea { position: relative; overflow: hidden; +} +.resultAreaAnimated { /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ transition: height 0.1s; } diff --git a/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.tsx b/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.tsx index 0bb2437b4..25530c8c3 100644 --- a/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.tsx +++ b/src/components/middle/composer/AiMessageEditorModal/AiEditorShared.tsx @@ -23,6 +23,7 @@ type AiEditorResultAreaProps = { isLoading?: boolean; transitionKey?: number; className?: string; + loadingElement?: TeactNode; children: TeactNode; }; @@ -30,6 +31,7 @@ export const AiEditorResultArea = memo(({ isLoading, transitionKey, className, + loadingElement, children, }: AiEditorResultAreaProps) => { const contentRef = useRef(); @@ -45,15 +47,16 @@ export const AiEditorResultArea = memo(({ }); }, [children, isLoading, transitionKey]); - const displayHeight = height ?? MIN_HEIGHT; + const hasInitialized = height !== undefined; + const displayHeight = hasInitialized ? height : (isLoading ? MIN_HEIGHT : undefined); return (
- + {loadingElement || }
{ const { setAiMessageEditorStyleOptions, composeWithAiMessageEditor, + openAiToneEditorModal, + closeAiMessageEditorModal, + deleteAiTone, + openChatWithDraft, } = getActions(); const lang = useLang(); + const [toneToDelete, setToneToDelete] = useState(); + const [isCreatorDelete, setIsCreatorDelete] = useState(false); + + const handleConfirmDelete = useLastCallback(() => { + if (!toneToDelete) return; + deleteAiTone({ tone: toneToDelete }); + setToneToDelete(undefined); + }); + + const handleCloseDeleteConfirm = useLastCallback(() => { + setToneToDelete(undefined); + }); + const hasResult = Boolean(result?.resultText); const hasRequest = Boolean(selectedTone) || shouldEmojify; const shouldShowError = Boolean(error) && hasRequest; - const styleTabs = useMemo((): TabWithProperties[] => tones.map((entry) => ({ - customEmojiDocumentId: entry.emojiId, - title: entry.title, - })), [tones]); + const buildContextActions = useLastCallback((entry: ApiAiComposeToneType): MenuItemContextAction[] | undefined => { + if (!('id' in entry)) return undefined; + + const tone = getInputTone(entry); + const actions: MenuItemContextAction[] = []; + + if (entry.isCreator) { + actions.push({ + title: lang('AiToneEditStyle'), + icon: 'edit', + handler: () => { + openAiToneEditorModal({ toneToEdit: entry }); + }, + }); + } + + actions.push({ + title: lang('AiToneShareStyle'), + icon: 'forward', + handler: () => { + closeAiMessageEditorModal(); + openChatWithDraft({ text: { text: `${TME_LINK_PREFIX}addstyle/${entry.slug}` } }); + }, + }); + + actions.push({ + title: lang('AiToneDeleteStyle'), + icon: 'delete', + destructive: true, + handler: () => { + setToneToDelete(tone); + setIsCreatorDelete(Boolean(entry.isCreator)); + }, + }); + + return actions; + }); + + const styleTabs = useMemo((): TabWithProperties[] => { + const tabs: TabWithProperties[] = tones.map((entry) => ({ + customEmojiDocumentId: entry.emojiId, + title: entry.title, + contextActions: buildContextActions(entry), + })); + + if (tones.length) { + tabs.push({ icon: 'add', title: lang('AiToneEditorNewStyle') }); + } + + return tabs; + }, [tones, lang, buildContextActions]); const activeStyleIndex = tones.findIndex( (entry) => compareAiTones(selectedTone, getInputTone(entry)), ); const handleStyleSelect = useLastCallback((index: number) => { + if (index === tones.length) { + openAiToneEditorModal(); + return; + } const tone = getInputTone(tones[index]); setAiMessageEditorStyleOptions({ selectedTone: tone }); composeWithAiMessageEditor({ tone, isEmojify: shouldEmojify }); @@ -105,15 +181,26 @@ const AiTextStyleEditor = ({ return (
- +
+ {styleTabs.length > 0 && ( + + )} +
+ + + + + +
+
@@ -144,6 +231,16 @@ const AiTextStyleEditor = ({ textToCopy={displayText?.text} isHidden={isLoading || shouldShowError || !displayText?.text} /> + +
); }; @@ -152,6 +249,7 @@ export default memo(withGlobal( (global): Complete => { return { tones: global.aiComposeTones?.tones ?? MEMO_EMPTY_ARRAY, + isAiToneEditorOpen: Boolean(selectTabState(global).aiToneEditorModal), }; }, )(AiTextStyleEditor)); diff --git a/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.module.scss b/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.module.scss new file mode 100644 index 000000000..b22bb7239 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.module.scss @@ -0,0 +1,63 @@ +.emojiRow { + position: relative; + display: flex; + justify-content: center; + padding-bottom: 1rem; +} + +.emojiButton { + --custom-emoji-size: 3rem; + + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: 5rem; + height: 5rem; + padding: 0; + border: none; + border-radius: 50%; + + background: var(--color-background); + outline: none; + + transition: opacity 0.15s; + + &:hover { + opacity: 0.75; + } +} + +.emojiPlaceholderIcon { + font-size: 2rem; + color: var(--color-text-secondary); +} + +.input { + margin: 0.5rem; +} + +.promptInput { + margin-top: 1rem; +} + +.promptInput :global(.form-control) { + min-height: 5.625rem; +} + +.footer { + padding-top: 1rem; +} + +.deleteButton { + width: 100%; + margin-bottom: 0.5rem; + border-radius: 2rem; +} + +.createButton { + width: 100%; + border-radius: 2rem; +} diff --git a/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.tsx b/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.tsx new file mode 100644 index 000000000..3868e7982 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiToneEditorModal.tsx @@ -0,0 +1,243 @@ +import { memo, useEffect, useMemo, useState } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiAiComposeTone } from '../../../../api/types'; + +import { selectTabState } from '../../../../global/selectors'; +import { getInputTone } from '../../../../util/aiComposeTones'; +import buildClassName from '../../../../util/buildClassName'; + +import useFlag from '../../../../hooks/useFlag'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import CustomEmoji from '../../../common/CustomEmoji'; +import Icon from '../../../common/icons/Icon'; +import CheckboxField from '../../../gili/templates/CheckboxField'; +import Button from '../../../ui/Button'; +import ConfirmDialog from '../../../ui/ConfirmDialog'; +import InputText from '../../../ui/InputText'; +import TextArea from '../../../ui/TextArea'; +import AiToneEmojiPickerModal from './AiToneEmojiPickerModal.async'; +import Island from '@gili/layout/Island'; +import Modal, { ModalCloseButton, ModalHeader, ModalTitle } from '@gili/modal/Modal'; + +import styles from './AiToneEditorModal.module.scss'; + +const EMOJI_SIZE = 48; +const DEFAULT_TITLE_MAX_LENGTH = 12; +const DEFAULT_PROMPT_MAX_LENGTH = 1024; + +type OwnProps = { + isOpen: boolean; +}; + +type StateProps = { + toneToEdit?: ApiAiComposeTone; + titleMaxLength?: number; + promptMaxLength?: number; +}; + +const AiToneEditorModal = ({ + isOpen, + toneToEdit, + titleMaxLength = DEFAULT_TITLE_MAX_LENGTH, + promptMaxLength = DEFAULT_PROMPT_MAX_LENGTH, +}: OwnProps & StateProps) => { + const { + closeAiToneEditorModal, + createAiTone, + updateAiTone, + deleteAiTone, + } = getActions(); + + const lang = useLang(); + + const isEditMode = Boolean(toneToEdit); + + const [emojiId, setEmojiId] = useState(); + const [title, setTitle] = useState(''); + const [prompt, setPrompt] = useState(''); + const [shouldDisplayAuthor, setShouldDisplayAuthor] = useState(false); + const [isEmojiPickerOpen, openEmojiPicker, closeEmojiPicker] = useFlag(); + const [isDeleteConfirmOpen, openDeleteConfirm, closeDeleteConfirm] = useFlag(); + + useEffect(() => { + if (!isOpen) return; + if (toneToEdit) { + setEmojiId(toneToEdit.emojiId); + setTitle(toneToEdit.title); + setPrompt(toneToEdit.prompt || ''); + setShouldDisplayAuthor(Boolean(toneToEdit.authorId)); + } else { + setEmojiId(undefined); + setTitle(''); + setPrompt(''); + setShouldDisplayAuthor(false); + } + }, [isOpen, toneToEdit]); + + const canSubmit = Boolean(emojiId && title.trim() && prompt.trim()); + + const handleClose = useLastCallback(() => { + closeAiToneEditorModal(); + closeEmojiPicker(); + closeDeleteConfirm(); + }); + + const handleSubmit = useLastCallback(() => { + if (!canSubmit) return; + + if (isEditMode) { + const tone = getInputTone(toneToEdit); + updateAiTone({ + tone, + title: title.trim(), + emojiId: emojiId!, + prompt: prompt.trim(), + shouldDisplayAuthor: shouldDisplayAuthor || undefined, + }); + } else { + createAiTone({ + title: title.trim(), + emojiId: emojiId!, + prompt: prompt.trim(), + shouldDisplayAuthor: shouldDisplayAuthor || undefined, + }); + } + handleClose(); + }); + + const handleDelete = useLastCallback(() => { + if (!toneToEdit) return; + const tone = getInputTone(toneToEdit); + deleteAiTone({ tone }); + closeDeleteConfirm(); + handleClose(); + }); + + const handleTitleChange = useLastCallback((e: React.ChangeEvent) => { + setTitle(e.target.value); + }); + + const handlePromptChange = useLastCallback((e: React.ChangeEvent) => { + setPrompt(e.target.value); + }); + + const handleEmojiSelect = useLastCallback((emojiDocumentId: string) => { + setEmojiId(emojiDocumentId); + closeEmojiPicker(); + }); + + const modalTitle = lang(isEditMode ? 'AiToneEditorEditTitle' : 'AiToneEditorTitle'); + + const renderHeader = useMemo(() => ( + + + {modalTitle} + + ), [modalTitle]); + + return ( + <> + +
+ +
+ + + + +