diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index f0aca9047..9f7e292b8 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -130,6 +130,7 @@ export interface GramJsAppConfig extends LimitsConfig { whitelisted_bots?: string[]; settings_display_passkeys?: boolean; passkeys_account_passkeys_max?: number; + ai_compose_styles?: [string, string, string][]; } function buildEmojiSounds(appConfig: GramJsAppConfig) { @@ -161,6 +162,11 @@ function buildDiceEmojiesSuccess(appConfig: GramJsAppConfig) { }, {} as ApiAppConfig['diceEmojiesSuccess']) : {}; } +function buildAiComposeStyles(appConfig: GramJsAppConfig) { + const { ai_compose_styles } = appConfig; + return ai_compose_styles?.map(([tone, documentId, title]) => ({ tone, documentId, title })); +} + function getLimit(appConfig: GramJsAppConfig, key: Limit, fallbackKey: ApiLimitType) { const defaultLimit = appConfig[`${key}_default`] || DEFAULT_LIMITS[fallbackKey][0]; const premiumLimit = appConfig[`${key}_premium`] || DEFAULT_LIMITS[fallbackKey][1]; @@ -272,6 +278,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp passkeysMaxCount: appConfig.passkeys_account_passkeys_max, diceEmojies: appConfig.emojies_send_dice, diceEmojiesSuccess: buildDiceEmojiesSuccess(appConfig), + aiComposeStyles: buildAiComposeStyles(appConfig), }; return { diff --git a/src/api/gramjs/apiBuilders/common.ts b/src/api/gramjs/apiBuilders/common.ts index 0bafc7b3a..6721b2ccc 100644 --- a/src/api/gramjs/apiBuilders/common.ts +++ b/src/api/gramjs/apiBuilders/common.ts @@ -3,6 +3,7 @@ import type { Entity } from '../../../lib/gramjs/types'; import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils'; import type { + ApiComposedMessageWithAI, ApiFormattedText, ApiMessageEntity, ApiMessageEntityDefault, @@ -301,9 +302,43 @@ export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMess }; } + if (entity instanceof GramJs.MessageEntityDiffInsert) { + return { + type: ApiMessageEntityTypes.DiffInsert, + offset, + length, + }; + } + + if (entity instanceof GramJs.MessageEntityDiffReplace) { + return { + type: ApiMessageEntityTypes.DiffReplace, + offset, + length, + oldText: entity.oldText, + }; + } + + if (entity instanceof GramJs.MessageEntityDiffDelete) { + return { + type: ApiMessageEntityTypes.DiffDelete, + offset, + length, + }; + } + return { type: type as `${ApiMessageEntityDefault['type']}`, offset, length, }; } + +export function buildApiComposedMessageWithAI( + result: GramJs.messages.ComposedMessageWithAI, +): ApiComposedMessageWithAI { + return { + resultText: buildApiFormattedText(result.resultText), + diffText: result.diffText ? buildApiFormattedText(result.diffText) : undefined, + }; +} diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index c1f36c4f2..81fab9180 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -452,6 +452,7 @@ export async function fetchCurrentUser() { export function dispatchErrorUpdate(err: Error, request: T) { const message = err instanceof RPCError ? err.errorMessage : err.message; + const isSlowMode = message === 'FLOOD' && ( request instanceof GramJs.messages.SendMessage || request instanceof GramJs.messages.SendMedia diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index efa260389..5ec8ccd4a 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -10,6 +10,7 @@ import type { import type { ApiAttachment, ApiChat, + ApiComposedMessageWithAI, ApiError, ApiFormattedText, ApiGlobalMessageSearchType, @@ -60,7 +61,7 @@ import { buildApiSponsoredMessageReportResult, buildThreadReadState, } from '../apiBuilders/chats'; -import { buildApiFormattedText } from '../apiBuilders/common'; +import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common'; import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, @@ -126,6 +127,7 @@ type TranslateTextParams = ({ messageIds: number[]; }) & { toLanguageCode: string; + tone?: string; }; type SearchResults = { @@ -2379,17 +2381,19 @@ export async function translateText(params: TranslateTextParams) { let result; const isMessageTranslation = 'chat' in params; if (isMessageTranslation) { - const { chat, messageIds, toLanguageCode } = params; + const { chat, messageIds, toLanguageCode, tone } = params; result = await invokeRequest(new GramJs.messages.TranslateText({ peer: buildInputPeer(chat.id, chat.accessHash), id: messageIds, toLang: toLanguageCode, + tone, })); } else { - const { text, toLanguageCode } = params; + const { text, toLanguageCode, tone } = params; result = await invokeRequest(new GramJs.messages.TranslateText({ text: text.map((t) => buildInputTextWithEntities(t)), toLang: toLanguageCode, + tone, })); } @@ -2421,14 +2425,15 @@ export async function translateText(params: TranslateTextParams) { } export async function fetchMessageSummary({ - chat, id, toLanguageCode, + chat, id, toLanguageCode, tone, }: { - chat: ApiChat; id: number; toLanguageCode?: string; + chat: ApiChat; id: number; toLanguageCode?: string; tone?: string; }) { const result = await invokeRequest(new GramJs.messages.SummarizeText({ peer: buildInputPeer(chat.id, chat.accessHash), id, toLang: toLanguageCode, + tone, })); if (!result) return undefined; @@ -2661,3 +2666,41 @@ export async function fetchPreparedInlineMessage({ export function incrementLocalMessagesCounter() { incrementLocalMessageCounter(); } + +export async function composeMessageWithAI({ + text, + shouldProofread, + isEmojify, + translateToLang, + changeTone, +}: { + text: ApiFormattedText; + shouldProofread?: boolean; + isEmojify?: boolean; + translateToLang?: string; + changeTone?: string; +}): Promise<{ result?: ApiComposedMessageWithAI; error?: 'floodPremium' | 'aiError' | 'generic' }> { + try { + const result = await invokeRequest(new GramJs.messages.ComposeMessageWithAI({ + text: buildInputTextWithEntities(text), + proofread: shouldProofread || undefined, + emojify: isEmojify || undefined, + translateToLang, + changeTone, + }), { shouldThrow: true }); + + if (!result) return { error: 'generic' }; + + return { result: buildApiComposedMessageWithAI(result) }; + } catch (err) { + if (err instanceof RPCError) { + if (err.errorMessage === 'AICOMPOSE_FLOOD_PREMIUM') { + return { error: 'floodPremium' }; + } + if (err.errorMessage === 'AICOMPOSE_ERROR_OCCURED') { + return { error: 'aiError' }; + } + } + return { error: 'generic' }; + } +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index c36848218..52304abed 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -510,7 +510,8 @@ export type ApiMessageEntityDefault = { `${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | - `${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}` + `${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}` | + `${ApiMessageEntityTypes.DiffInsert}` | `${ApiMessageEntityTypes.DiffReplace}` | `${ApiMessageEntityTypes.DiffDelete}` >; offset: number; length: number; @@ -572,9 +573,28 @@ export type ApiMessageEntityTimestamp = { timestamp: number; }; +export type ApiMessageEntityDiffInsert = { + type: ApiMessageEntityTypes.DiffInsert; + offset: number; + length: number; +}; + +export type ApiMessageEntityDiffReplace = { + type: ApiMessageEntityTypes.DiffReplace; + offset: number; + length: number; + oldText: string; +}; + +export type ApiMessageEntityDiffDelete = { + type: ApiMessageEntityTypes.DiffDelete; + offset: number; + length: number; +}; + export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl | ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp | - ApiMessageEntityFormattedDate; + ApiMessageEntityFormattedDate | ApiMessageEntityDiffInsert | ApiMessageEntityDiffReplace | ApiMessageEntityDiffDelete; export enum ApiMessageEntityTypes { Bold = 'MessageEntityBold', @@ -599,6 +619,9 @@ export enum ApiMessageEntityTypes { QuoteFocus = 'MessageEntityQuoteFocus', FormattedDate = 'MessageEntityFormattedDate', Unknown = 'MessageEntityUnknown', + DiffInsert = 'MessageEntityDiffInsert', + DiffReplace = 'MessageEntityDiffReplace', + DiffDelete = 'MessageEntityDiffDelete', } export interface ApiFormattedText { @@ -610,6 +633,11 @@ export interface ApiFormattedTextWithEmojiOnlyCount extends ApiFormattedText { emojiOnlyCount?: number; } +export interface ApiComposedMessageWithAI { + resultText: ApiFormattedText; + diffText?: ApiFormattedText; +} + export type MediaContent = { text?: ApiFormattedTextWithEmojiOnlyCount; photo?: ApiPhoto; diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index 2138269e2..fc04ca803 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -242,6 +242,12 @@ export interface ApiCountryCode extends ApiCountry { patterns?: string[]; } +export interface ApiAiComposeStyle { + tone: string; + documentId: string; + title: string; +} + export interface ApiAppConfig { hash: number; emojiSounds: Record; @@ -331,6 +337,7 @@ export interface ApiAppConfig { value: number; frameStart: number; }>; + aiComposeStyles?: ApiAiComposeStyle[]; } export interface ApiConfig { diff --git a/src/assets/font-icons/ai-edit.svg b/src/assets/font-icons/ai-edit.svg new file mode 100644 index 000000000..cd5fa80f6 --- /dev/null +++ b/src/assets/font-icons/ai-edit.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/ai-fix.svg b/src/assets/font-icons/ai-fix.svg new file mode 100644 index 000000000..a5c3a33af --- /dev/null +++ b/src/assets/font-icons/ai-fix.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/ai.svg b/src/assets/font-icons/ai.svg new file mode 100644 index 000000000..baab69418 --- /dev/null +++ b/src/assets/font-icons/ai.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 85eea8264..1bd0c7508 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -2386,6 +2386,8 @@ "ToDoListErrorChooseTasks" = "Please enter at least one task."; "GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**"; "PremiumPreviewTodo" = "Checklists"; +"PremiumPreviewAiTools" = "AI Tools"; +"PremiumPreviewAiToolsDescription" = "Transform your messages and entire chats in your preferred style and language."; "NativeDownloadFailed" = "Failed to save file to the Downloads folder"; "DescriptionAboutTon" = "Offer TON to submit post suggestions to channels on Telegram."; "ButtonTopUpViaFragment" = "Top Up Via Fragment"; @@ -2814,4 +2816,30 @@ "ReminderSetToast" = "You set up a reminder in **Saved Messages**"; "NoForwardsRequestReject" = "Reject"; "NoForwardsRequestAccept" = "Accept"; -"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure." +"AiMessageEditor" = "AI Editor"; +"AiMessageEditorTranslate" = "Translate"; +"AiMessageEditorStyle" = "Style"; +"AiMessageEditorFix" = "Fix"; +"AiMessageEditorSelectStyle" = "Select Style"; +"AiMessageEditorDailyLimitReached" = "Daily limit reached. To increase limits, get {link}."; +"AiMessageEditorDailyLimitReachedPremium" = "Daily limit reached."; +"AiMessageEditorGenericError" = "Please try again later."; +"AiMessageEditorStyleFormal" = "Formal"; +"AiMessageEditorStyleShort" = "Short"; +"AiMessageEditorStyleTribal" = "Tribal"; +"AiMessageEditorStyleCorp" = "Corp"; +"AiMessageEditorStyleBiblical" = "Biblical"; +"AiMessageEditorStyleViking" = "Viking"; +"AiMessageEditorStyleZen" = "Zen"; +"AiMessageEditorResult" = "Result"; +"AiMessageEditorOriginal" = "Original"; +"AiMessageEditorApply" = "Apply"; +"AiMessageEditorEmojify" = "emojify"; +"AiMessageEditorTranslation" = "Translation"; +"TextShowMore" = "more"; +"TextShowLess" = "less"; +"AiMessageEditorFrom" = "From"; +"AiMessageEditorTo" = "To"; +"TranslationToneNeutral" = "Neutral"; +"ButtonHelp" = "Help"; +"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure."; diff --git a/src/assets/premium/PremiumAi.svg b/src/assets/premium/PremiumAi.svg new file mode 100644 index 000000000..453c47cb6 --- /dev/null +++ b/src/assets/premium/PremiumAi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 72b99ed87..948cac0f6 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -71,6 +71,9 @@ export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuC export { default as MiddleSearch } from '../components/middle/search/MiddleSearch'; export { default as ReactionPicker } from '../components/middle/message/reactions/ReactionPicker'; +export { default as AiMessageEditorModal } + from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal'; + export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as PollModal } from '../components/middle/composer/PollModal'; export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal'; diff --git a/src/components/common/Composer.scss b/src/components/common/Composer.scss index be36743ff..f3cb8a29e 100644 --- a/src/components/common/Composer.scss +++ b/src/components/common/Composer.scss @@ -418,6 +418,28 @@ background: var(--color-background); box-shadow: 0 1px 2px var(--color-default-shadow); + .ai-composer-button { + position: absolute; + z-index: 1; + top: 0.4375rem; + right: 0.625rem; + + width: 1.75rem; + height: 1.75rem; + + color: var(--color-composer-button); + + opacity: 1; + + transition: opacity 150ms ease-out; + } + + .ai-composer-button-hidden { + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; + } + &.with-story-tweaks { border-radius: var(--border-radius-default-small); border-bottom-right-radius: 0; diff --git a/src/components/common/Composer.tsx b/src/components/common/Composer.tsx index 46ac0999d..5d9819b15 100644 --- a/src/components/common/Composer.tsx +++ b/src/components/common/Composer.tsx @@ -119,6 +119,7 @@ import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates import { processDeepLink } from '../../util/deeplink'; import { tryParseDeepLink } from '../../util/deepLinkParser'; import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection'; +import calcTextLineHeightAndCount from '../../util/element/calcTextLineHeightAndCount'; import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager'; import { isUserId } from '../../util/entities/ids'; import { fetchBlob } from '../../util/files'; @@ -254,6 +255,7 @@ type StateProps = { forwardedMessagesCount?: number; pollModal: TabState['pollModal']; todoListModal: TabState['todoListModal']; + aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult']; botKeyboardMessageId?: number; botKeyboardPlaceholder?: string; withScheduledButton?: boolean; @@ -382,6 +384,7 @@ const Composer = ({ forwardedMessagesCount, pollModal, todoListModal, + aiMessageEditorPendingResult, botKeyboardMessageId, botKeyboardPlaceholder, inputPlaceholder, @@ -457,11 +460,14 @@ const Composer = ({ const { sendMessage, clearDraft, + saveDraft, showDialog, openPollModal, closePollModal, openTodoListModal, closeTodoListModal, + openAiMessageEditorModal, + clearAiMessageEditorPendingResult, loadScheduledHistory, openThread, addRecentEmoji, @@ -859,6 +865,25 @@ const Composer = ({ updateInsertingPeerIdMention({ peerId: undefined }); }, [insertingPeerIdMention, insertMention]); + useEffect(() => { + if (!aiMessageEditorPendingResult) return; + + const { text, shouldClear, shouldSendWithAttachments } = aiMessageEditorPendingResult; + + if (shouldSendWithAttachments) return; + + if (shouldClear) { + setHtml(''); + clearDraft({ chatId, threadId, isLocalOnly: true }); + } else if (text) { + setHtml(getTextWithEntitiesAsHtml(text)); + saveDraft({ chatId, threadId, text }); + } + + clearAiMessageEditorPendingResult(); + }, [aiMessageEditorPendingResult, chatId, clearDraft, + clearAiMessageEditorPendingResult, saveDraft, setHtml, threadId]); + const { isOpen: isInlineBotTooltipOpen, botId: inlineBotId, @@ -1369,6 +1394,14 @@ const Composer = ({ openTodoListModal({ chatId }); }); + const handleOpenAiEditor = useLastCallback(() => { + const { text, entities } = parseHtmlAsFormattedText(getHtml()); + openAiMessageEditorModal({ + chatId, + text: { text, entities }, + }); + }); + const handleClickBotMenu = useLastCallback(() => { if (botMenuButton?.type !== 'webApp') { return; @@ -1788,7 +1821,25 @@ const Composer = ({ }; }, [isSelectModeActive, enableHover, disableHover, isReady]); - const hasText = useDerivedState(() => Boolean(getHtml()), [getHtml]); + const html = useDerivedState(() => getHtml(), [getHtml]); + const hasText = Boolean(html); + const [shouldShowAiButton, setShouldShowAiButton] = useState(false); + + useEffect(() => { + if (hasAttachments) { + return; + } + + requestMeasure(() => { + const input = inputRef.current; + if (!html || !input) { + setShouldShowAiButton(false); + return; + } + const { totalLines } = calcTextLineHeightAndCount(input, true); + setShouldShowAiButton(totalLines >= 3); + }); + }, [html, hasAttachments]); const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage && messageListType === 'thread'; @@ -2012,8 +2063,11 @@ const Composer = ({ }); const handleSendScheduledAttachments = useLastCallback( - (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => { - requestCalendar((scheduledAt, scheduleRepeatPeriod) => { + ( + sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true, + scheduledAt?: number, scheduleRepeatPeriod?: number, + ) => { + if (scheduledAt) { handleActionWithPaymentConfirmation( handleMessageSchedule, { sendCompressed, sendGrouped, isInvertedMedia }, @@ -2022,7 +2076,18 @@ const Composer = ({ currentMessageList!, undefined, ); - }); + } else { + requestCalendar((calendarScheduledAt, calendarRepeatPeriod) => { + handleActionWithPaymentConfirmation( + handleMessageSchedule, + { sendCompressed, sendGrouped, isInvertedMedia }, + calendarScheduledAt, + calendarRepeatPeriod, + currentMessageList!, + undefined, + ); + }); + } }, ); @@ -2191,6 +2256,17 @@ const Composer = ({ )} + + + + {isOpen && !isInScheduledList && ( + + )} + + {calendar} + + + ); +}; + +export default memo(withGlobal( + (global, { modal }): Complete => { + const chatId = modal?.chatId; + const currentMessageList = selectCurrentMessageList(global); + const tabState = selectTabState(global); + const chat = chatId ? selectChat(global, chatId) : undefined; + const paidMessagesStars = chatId ? selectPeerPaidMessagesStars(global, chatId) : undefined; + const starsBalance = global.stars?.balance.amount || 0; + const isStarsBalanceModalOpen = Boolean(tabState.starsBalanceModal); + + return { + animationLevel: selectAnimationLevel(global), + isPremium: selectIsCurrentUserPremium(global), + isChatWithSelf: chatId ? selectIsChatWithSelf(global, chatId) : undefined, + canScheduleUntilOnline: currentMessageList?.chatId + ? selectCanScheduleUntilOnline(global, currentMessageList.chatId) + : undefined, + isInScheduledList: currentMessageList?.type === 'scheduled', + chat, + paidMessagesStars, + isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen, + starsBalance, + isStarsBalanceModalOpen, + }; + }, +)(AiMessageEditorModal)); diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.module.scss b/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.module.scss new file mode 100644 index 000000000..9c67f1efa --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.module.scss @@ -0,0 +1,3 @@ +.section { + margin-bottom: 0; +} diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.tsx b/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.tsx new file mode 100644 index 000000000..e96d95562 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextFixEditor.tsx @@ -0,0 +1,80 @@ +import { memo } from '../../../../lib/teact/teact'; + +import type { ApiComposedMessageWithAI, ApiFormattedText } from '../../../../api/types'; + +import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; + +import useLang from '../../../../hooks/useLang'; + +import ExpandableText from '../../../ui/ExpandableText'; +import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared'; + +import sharedStyles from './AiEditorShared.module.scss'; +import modalStyles from './AiMessageEditorModal.module.scss'; +import styles from './AiTextFixEditor.module.scss'; + +type OwnProps = { + text?: ApiFormattedText; + isLoading?: boolean; + result?: ApiComposedMessageWithAI; + error?: 'floodPremium' | 'aiError' | 'generic'; + isPremium?: boolean; +}; + +const AiTextFixEditor = ({ + text, + isLoading, + result, + error, + isPremium, +}: OwnProps) => { + const lang = useLang(); + + const hasError = Boolean(error); + const displayResult = result?.diffText || result?.resultText; + + function renderResultText() { + if (hasError) { + return ; + } + + return displayResult && renderDiffText(displayResult); + } + + return ( +
+
+
+ + {lang('AiMessageEditorOriginal')} + +
+ +
+ +
+ +
+ {lang('AiMessageEditorResult')} +
+ + {renderResultText()} + + +
+ ); +}; + +function renderDiffText(formattedText: ApiFormattedText) { + const { text, entities } = formattedText; + + return renderTextWithEntities({ + text, + entities, + }); +} + +export default memo(AiTextFixEditor); diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.module.scss b/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.module.scss new file mode 100644 index 000000000..e0a54f2f3 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.module.scss @@ -0,0 +1,39 @@ +.styleBlock { + padding-top: 0; +} + +.tabList { + --tab-radius: 1rem; + + margin-top: 0.4375rem; + margin-bottom: 0; + margin-inline: -1rem; + padding-inline: 0.9375rem; + border-radius: 0 !important; + + box-shadow: none; +} + +.tabListIndicator { + padding-inline: 0.9375rem; +} + +.tab { + gap: 0.125rem; + padding: 0.125rem 0.625rem; + font-size: 0.875rem; +} + +.textLabel, +.resultLabel { + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text); +} + +.previewText { + font-size: 0.9375rem; + line-height: 1.4; + overflow-wrap: anywhere; + white-space: pre-wrap; +} diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.tsx b/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.tsx new file mode 100644 index 000000000..bfbeca072 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextStyleEditor.tsx @@ -0,0 +1,164 @@ +import { memo, useMemo } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiAiComposeStyle, ApiComposedMessageWithAI, ApiFormattedText } from '../../../../api/types'; +import type { TabWithProperties } from '../../../ui/TabList'; +import { ApiMessageEntityTypes } from '../../../../api/types'; + +import EMOJI_REGEX from '../../../../lib/twemojiRegex'; +import buildClassName from '../../../../util/buildClassName'; +import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; +import { getStyleTitle } from './helpers'; + +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import CheckboxField from '../../../gili/templates/CheckboxField'; +import TabList from '../../../ui/TabList'; +import Transition from '../../../ui/Transition'; +import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared'; + +import sharedStyles from './AiEditorShared.module.scss'; +import modalStyles from './AiMessageEditorModal.module.scss'; +import styles from './AiTextStyleEditor.module.scss'; + +type OwnProps = { + text?: ApiFormattedText; + selectedTone?: string; + shouldEmojify?: boolean; + isLoading?: boolean; + result?: ApiComposedMessageWithAI; + error?: 'floodPremium' | 'aiError' | 'generic'; + isPremium?: boolean; +}; + +type StateProps = { + aiComposeStyles: ApiAiComposeStyle[]; +}; + +const AiTextStyleEditor = ({ + text, + selectedTone, + shouldEmojify, + isLoading, + result, + error, + isPremium, + aiComposeStyles, +}: OwnProps & StateProps) => { + const { + setAiMessageEditorStyleOptions, + composeWithAiMessageEditor, + } = getActions(); + + const lang = useLang(); + + const hasResult = Boolean(result?.resultText); + const hasRequest = Boolean(selectedTone) || shouldEmojify; + const shouldShowError = Boolean(error) && hasRequest; + + const styleTabs = useMemo((): TabWithProperties[] => aiComposeStyles.map(({ documentId, title, tone }) => { + const emojiMatch = title.match(EMOJI_REGEX); + const localizedTitle = getStyleTitle(lang, tone, title); + + return { + emoticon: documentId ? { + type: ApiMessageEntityTypes.CustomEmoji, + offset: 0, + length: emojiMatch?.[0].length || 2, + documentId, + } : undefined, + title: localizedTitle, + }; + }), [aiComposeStyles, lang]); + + const activeStyleIndex = aiComposeStyles.findIndex(({ tone }) => tone === selectedTone); + + const handleStyleSelect = useLastCallback((index: number) => { + const styleId = aiComposeStyles[index].tone; + setAiMessageEditorStyleOptions({ selectedTone: styleId }); + composeWithAiMessageEditor({ changeTone: styleId, isEmojify: shouldEmojify }); + }); + + const handleEmojifyChange = useLastCallback((newEmojify: boolean) => { + if (!selectedTone && !newEmojify) { + setAiMessageEditorStyleOptions({ shouldEmojify: newEmojify, clearResult: true }); + } else { + setAiMessageEditorStyleOptions({ shouldEmojify: newEmojify }); + composeWithAiMessageEditor({ changeTone: selectedTone, isEmojify: newEmojify }); + } + }); + + const displayText = hasResult ? result?.resultText : text; + const showResultLabel = hasRequest || isLoading; + const displayLabel = showResultLabel ? lang('AiMessageEditorResult') : lang('AiMessageEditorOriginal'); + + const transitionKey = (activeStyleIndex >= 0 ? activeStyleIndex : 0) + (shouldEmojify ? aiComposeStyles.length : 0); + + function renderPreviewText() { + if (shouldShowError) { + return ; + } + + return ( +
+ {displayText?.text && renderTextWithEntities({ + text: displayText.text, + entities: displayText.entities, + })} +
+ ); + } + + return ( +
+ + +
+ +
+ + {displayLabel} + + +
+ + + {renderPreviewText()} + + +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + aiComposeStyles: global.appConfig?.aiComposeStyles ?? [], + }; + }, +)(AiTextStyleEditor)); diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.module.scss b/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.module.scss new file mode 100644 index 000000000..4634f09c4 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.module.scss @@ -0,0 +1,71 @@ +.section { + margin-bottom: 0; +} + +.sourceLanguage { + padding-inline-start: 0.375rem; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-primary); +} + +.languageLink { + cursor: pointer; + + display: inline-flex; + gap: 0.125rem; + align-items: center; + + padding: 0.125rem 0.375rem; + border-radius: 0.75rem; + + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-primary); + + transition: background-color 0.15s; + + .icon { + font-size: 0.75rem; + } + + &:hover { + background-color: var(--color-primary-opacity); + } +} + +.languageMenu :global(.bubble) { + left: -2rem !important; + + overflow: initial; + + min-width: 10rem; + padding: 0 !important; + + background: none !important; + backdrop-filter: none !important; + box-shadow: none !important; +} + +.languageMenu :global(.TranslationToneSelector) { + position: absolute; + top: 0; + left: 100%; + transform: translateX(0.5rem); +} + +.languageItems { + overflow: auto; + + max-height: 16rem; + padding: 0.25rem 0; + border-radius: var(--border-radius-default); + + background: var(--color-background); + box-shadow: 0 0.25rem 0.5rem 0.125rem var(--color-default-shadow); + + body:not(.no-menu-blur) & { + background: var(--color-background-compact-menu); + backdrop-filter: blur(10px); + } +} diff --git a/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.tsx b/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.tsx new file mode 100644 index 000000000..3e7bb757e --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/AiTextTranslateEditor.tsx @@ -0,0 +1,244 @@ +import { memo, useMemo, useRef, useState } from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiAiComposeStyle, ApiComposedMessageWithAI, ApiFormattedText } from '../../../../api/types'; +import type { IAnchorPosition } from '../../../../types'; + +import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../../../config'; +import buildClassName from '../../../../util/buildClassName'; +import { renderTextWithEntities } from '../../../common/helpers/renderTextWithEntities'; + +import useFlag from '../../../../hooks/useFlag'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import useTextLanguage from '../../../../hooks/useTextLanguage'; + +import CheckboxField from '../../../gili/templates/CheckboxField'; +import ExpandableText from '../../../ui/ExpandableText'; +import Menu from '../../../ui/Menu'; +import MenuItem from '../../../ui/MenuItem'; +import TranslationToneSelector from '../../message/TranslationToneSelector'; +import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared'; + +import sharedStyles from './AiEditorShared.module.scss'; +import modalStyles from './AiMessageEditorModal.module.scss'; +import styles from './AiTextTranslateEditor.module.scss'; + +const EMPTY_AI_COMPOSE_STYLES: ApiAiComposeStyle[] = []; + +type OwnProps = { + text?: ApiFormattedText; + selectedLanguage?: string; + selectedTone?: string; + shouldEmojify?: boolean; + isLoading?: boolean; + result?: ApiComposedMessageWithAI; + error?: 'floodPremium' | 'aiError' | 'generic'; + isPremium?: boolean; +}; + +type StateProps = { + aiComposeStyles: ApiAiComposeStyle[]; +}; + +const AiTextTranslateEditor = ({ + text, + selectedLanguage, + selectedTone, + shouldEmojify, + isLoading, + result, + error, + isPremium, + aiComposeStyles, +}: OwnProps & StateProps) => { + const { + setAiMessageEditorTranslateOptions, + composeWithAiMessageEditor, + } = getActions(); + + const lang = useLang(); + + const [isMenuOpen, openMenu, closeMenu] = useFlag(false); + const [menuAnchor, setMenuAnchor] = useState(); + + const triggerRef = useRef(); + + const detectedLanguage = useTextLanguage(text?.text); + const hasError = Boolean(error); + + const currentLanguageCode = lang.code; + + const languages = useMemo(() => SUPPORTED_TRANSLATION_LANGUAGES.map((langCode: string) => { + const displayNames = new Intl.DisplayNames([currentLanguageCode], { type: 'language' }); + const displayName = displayNames.of(langCode) || langCode; + + return { + langCode, + displayName, + }; + }), [currentLanguageCode]); + + const detectedLanguageName = useMemo(() => { + if (!detectedLanguage) return undefined; + const displayNames = new Intl.DisplayNames([currentLanguageCode], { type: 'language' }); + return displayNames.of(detectedLanguage); + }, [detectedLanguage, currentLanguageCode]); + + const selectedLanguageName = useMemo(() => { + if (!selectedLanguage) return undefined; + const displayNames = new Intl.DisplayNames([currentLanguageCode], { type: 'language' }); + return displayNames.of(selectedLanguage); + }, [selectedLanguage, currentLanguageCode]); + + const handleLanguageSelect = useLastCallback((langCode: string) => { + setAiMessageEditorTranslateOptions({ selectedLanguage: langCode }); + composeWithAiMessageEditor({ + translateToLang: langCode, + isEmojify: shouldEmojify, + changeTone: selectedTone, + }); + }); + + const handleEmojifyChange = useLastCallback((newEmojify: boolean) => { + setAiMessageEditorTranslateOptions({ shouldEmojify: newEmojify }); + if (selectedLanguage) { + composeWithAiMessageEditor({ + translateToLang: selectedLanguage, + isEmojify: newEmojify, + changeTone: selectedTone, + }); + } + }); + + const handleTriggerClick = useLastCallback(() => { + if (triggerRef.current) { + const rect = triggerRef.current.getBoundingClientRect(); + setMenuAnchor({ x: rect.left, y: rect.bottom }); + openMenu(); + } + }); + + const getTriggerElement = useLastCallback(() => triggerRef.current); + const getRootElement = useLastCallback(() => document.body); + const getMenuElement = useLastCallback(() => document.querySelector('.language-menu .bubble')); + const getLayout = useLastCallback(() => ({ withPortal: true })); + + const handleToneSelect = useLastCallback((tone?: string) => { + setAiMessageEditorTranslateOptions({ selectedTone: tone }); + if (selectedLanguage) { + composeWithAiMessageEditor({ + translateToLang: selectedLanguage, + isEmojify: shouldEmojify, + changeTone: tone, + }); + } + }); + + const displayResult = result?.resultText; + + const languageIndex = SUPPORTED_TRANSLATION_LANGUAGES.indexOf(selectedLanguage || ''); + const toneIndex = aiComposeStyles.findIndex(({ tone }) => tone === selectedTone); + const totalLanguages = SUPPORTED_TRANSLATION_LANGUAGES.length; + const totalTones = aiComposeStyles.length; + const transitionKey = languageIndex + + (toneIndex + 1) * totalLanguages + + (shouldEmojify ? totalLanguages * (totalTones + 1) : 0); + + function renderResultText() { + if (hasError) { + return ; + } + + return displayResult && renderTextWithEntities({ + text: displayResult.text, + entities: displayResult.entities, + }); + } + + return ( +
+
+
+ + {lang('AiMessageEditorFrom')} + + + {detectedLanguageName} + +
+ +
+ +
+ +
+
+ + {lang('AiMessageEditorTo')} + + + {selectedLanguageName} + + + + +
+ {languages.map(({ langCode, displayName }) => ( + handleLanguageSelect(langCode)} + > + {displayName} + + ))} +
+
+
+ +
+ + + {renderResultText()} + + +
+ ); +}; + +export default memo(withGlobal( + (global): Complete => { + return { + aiComposeStyles: global.appConfig.aiComposeStyles || EMPTY_AI_COMPOSE_STYLES, + }; + }, +)(AiTextTranslateEditor)); diff --git a/src/components/middle/composer/AiMessageEditorModal/helpers.ts b/src/components/middle/composer/AiMessageEditorModal/helpers.ts new file mode 100644 index 000000000..39f2e1a49 --- /dev/null +++ b/src/components/middle/composer/AiMessageEditorModal/helpers.ts @@ -0,0 +1,10 @@ +import type { LangFn } from '../../../../util/localization'; + +export function getStyleTitle(lang: LangFn, tone: string, fallbackTitle: string) { + if (!tone) return fallbackTitle; + const capitalizedTone = tone.charAt(0).toUpperCase() + tone.slice(1); + const key = `AiMessageEditorStyle${capitalizedTone}`; + // @ts-ignore - Dynamic lang key + const translated = lang(key); + return translated !== key ? translated : fallbackTitle; +} diff --git a/src/components/middle/composer/AttachmentModal.module.scss b/src/components/middle/composer/AttachmentModal.module.scss index 7a1c36c14..4a59f8c3f 100644 --- a/src/components/middle/composer/AttachmentModal.module.scss +++ b/src/components/middle/composer/AttachmentModal.module.scss @@ -42,7 +42,7 @@ } .symbol-menu-button, .mobile-symbol-menu-button { - align-self: center; + align-self: flex-end; margin-right: -1.5rem; margin-left: -0.25rem !important; color: var(--color-composer-button); @@ -146,6 +146,27 @@ align-items: center; } +.aiButton { + position: absolute; + z-index: 3; + top: 0.5rem; + right: 0.5rem; + + width: 1.75rem; + height: 1.75rem; + + color: var(--color-composer-button); + + opacity: 1; + + transition: opacity 150ms ease-out; +} + +.aiButtonHidden { + pointer-events: none; + opacity: 0; +} + .dropTarget { position: relative; overflow: hidden; @@ -219,6 +240,8 @@ .send-wrapper { position: relative; + align-self: flex-end; + margin-bottom: 0.1875rem; } .send { diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 5a14a2c66..9a07029ba 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -2,7 +2,7 @@ import { type FC, memo, useEffect, useMemo, useRef, useState } from '../../../li import { getActions, withGlobal } from '../../../global'; import type { ApiAttachment, ApiChatMember, ApiMessage, ApiSticker } from '../../../api/types'; -import type { GlobalState } from '../../../global/types'; +import type { GlobalState, TabState } from '../../../global/types'; import type { MessageListType, ThreadId } from '../../../types'; import type { Signal } from '../../../util/signals'; @@ -13,17 +13,20 @@ import { SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; -import { requestMutation } from '../../../lib/fasterdom/fasterdom'; +import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom'; import { getAttachmentMediaType } from '../../../global/helpers'; import { selectChatFullInfo, selectIsChatWithSelf, selectTabState } from '../../../global/selectors'; import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectSharedSettings } from '../../../global/selectors/sharedState'; import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; +import calcTextLineHeightAndCount from '../../../util/element/calcTextLineHeightAndCount'; import { validateFiles } from '../../../util/files'; import { formatStarsAsIcon } from '../../../util/localization/format'; +import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText'; import { removeAllSelections } from '../../../util/selection'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; +import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities'; import buildAttachment from './helpers/buildAttachment'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import { getHtmlTextLength } from './helpers/getHtmlTextLength'; @@ -81,7 +84,10 @@ export type OwnProps = { onAttachmentsUpdate: (attachments: ApiAttachment[]) => void; onClear: NoneToVoidFunction; onSendSilent: (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => void; - onSendScheduled: (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => void; + onSendScheduled: ( + sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true, + scheduledAt?: number, scheduleRepeatPeriod?: number, + ) => void; onCustomEmojiSelect: (emoji: ApiSticker) => void; onRemoveSymbol: VoidFunction; onEmojiSelect: (emoji: string) => void; @@ -101,6 +107,7 @@ type StateProps = { attachmentSettings: GlobalState['attachmentSettings']; shouldSaveAttachmentsCompression?: boolean; shouldOpenMessageMediaEditor?: boolean; + aiMessageEditorPendingResult?: TabState['aiMessageEditorPendingResult']; }; const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text'; @@ -137,6 +144,7 @@ const AttachmentModal = ({ canSchedule, paidMessagesStars, shouldOpenMessageMediaEditor, + aiMessageEditorPendingResult, onAttachmentsUpdate, onCaptionUpdate, onSend, @@ -153,7 +161,7 @@ const AttachmentModal = ({ const svgRef = useRef(); const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest, - updateShouldSaveAttachmentsCompression, + updateShouldSaveAttachmentsCompression, openAiMessageEditorModal, clearAiMessageEditorPendingResult, } = getActions(); const lang = useLang(); @@ -174,6 +182,8 @@ const AttachmentModal = ({ const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag(); const [editingAttachmentIndex, setEditingAttachmentIndex] = useState(undefined); + const [shouldShowAiButton, setShouldShowAiButton] = useState(false); + const html = useDerivedState(() => getHtml(), [getHtml]); const editingAttachment = editingAttachmentIndex !== undefined ? attachments[editingAttachmentIndex] : undefined; @@ -308,22 +318,69 @@ const AttachmentModal = ({ handleContextMenuHide, } = useContextMenuHandlers(mainButtonRef, !canShowCustomSendMenu || !isOpen); - const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { - if (isOpen) { - const send = ((shouldSchedule || shouldSendScheduled) && isForMessage && !editingMessage) ? onSendScheduled - : isSilent ? onSendSilent : onSend; - send(isSendingCompressed, shouldSendGrouped, isInvertedMedia); - updateAttachmentSettings({ - ...(shouldSaveAttachmentsCompression && { - defaultAttachmentCompression: attachmentSettings.shouldCompress ? 'compress' : 'original', - }), - shouldSendGrouped, - isInvertedMedia, - shouldSendInHighQuality, - }); - } + useEffect(() => { + requestMeasure(() => { + const input = inputRef.current; + if (!html || !input) { + setShouldShowAiButton(false); + return; + } + const { totalLines } = calcTextLineHeightAndCount(input, true); + setShouldShowAiButton(totalLines >= 3); + }); + }, [html, isOpen]); + + const handleOpenAiEditor = useLastCallback(() => { + const { text, entities } = parseHtmlAsFormattedText(getHtml()); + openAiMessageEditorModal({ + chatId, + text: { text, entities }, + isFromAttachment: true, + }); }); + const sendAttachments = useLastCallback(( + isSilent?: boolean, scheduledAt?: number | true, scheduleRepeatPeriod?: number, + ) => { + if (!isOpen) return; + + const shouldSendScheduled = (shouldSchedule || scheduledAt) && isForMessage && !editingMessage; + if (shouldSendScheduled) { + const actualScheduledAt = typeof scheduledAt === 'number' ? scheduledAt : undefined; + onSendScheduled(isSendingCompressed, shouldSendGrouped, isInvertedMedia, actualScheduledAt, scheduleRepeatPeriod); + } else if (isSilent) { + onSendSilent(isSendingCompressed, shouldSendGrouped, isInvertedMedia); + } else { + onSend(isSendingCompressed, shouldSendGrouped, isInvertedMedia); + } + + updateAttachmentSettings({ + ...(shouldSaveAttachmentsCompression && { + defaultAttachmentCompression: attachmentSettings.shouldCompress ? 'compress' : 'original', + }), + shouldSendGrouped, + isInvertedMedia, + shouldSendInHighQuality, + }); + }); + + const handleSendWithAiResult = useLastCallback(() => { + if (!aiMessageEditorPendingResult?.shouldSendWithAttachments || !isOpen) return; + + const { text, isSilent, scheduledAt, scheduleRepeatPeriod } = aiMessageEditorPendingResult; + + if (text) { + onCaptionUpdate(getTextWithEntitiesAsHtml(text)); + } + + sendAttachments(isSilent, scheduledAt, scheduleRepeatPeriod); + clearAiMessageEditorPendingResult(); + }); + + useEffect(() => { + handleSendWithAiResult(); + }, [aiMessageEditorPendingResult, handleSendWithAiResult]); + const handleSendSilent = useLastCallback(() => { sendAttachments(true); }); @@ -749,6 +806,16 @@ const AttachmentModal = ({ styles.captionWrapper, )} > +