Composer: Support AI messages (#6826)

This commit is contained in:
Alexander Zinchuk 2026-04-14 14:36:48 +02:00
parent d7e3456550
commit d4138b0ebd
69 changed files with 2929 additions and 135 deletions

View File

@ -130,6 +130,7 @@ export interface GramJsAppConfig extends LimitsConfig {
whitelisted_bots?: string[]; whitelisted_bots?: string[];
settings_display_passkeys?: boolean; settings_display_passkeys?: boolean;
passkeys_account_passkeys_max?: number; passkeys_account_passkeys_max?: number;
ai_compose_styles?: [string, string, string][];
} }
function buildEmojiSounds(appConfig: GramJsAppConfig) { function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -161,6 +162,11 @@ function buildDiceEmojiesSuccess(appConfig: GramJsAppConfig) {
}, {} as ApiAppConfig['diceEmojiesSuccess']) : {}; }, {} 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) { function getLimit(appConfig: GramJsAppConfig, key: Limit, fallbackKey: ApiLimitType) {
const defaultLimit = appConfig[`${key}_default`] || DEFAULT_LIMITS[fallbackKey][0]; const defaultLimit = appConfig[`${key}_default`] || DEFAULT_LIMITS[fallbackKey][0];
const premiumLimit = appConfig[`${key}_premium`] || DEFAULT_LIMITS[fallbackKey][1]; 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, passkeysMaxCount: appConfig.passkeys_account_passkeys_max,
diceEmojies: appConfig.emojies_send_dice, diceEmojies: appConfig.emojies_send_dice,
diceEmojiesSuccess: buildDiceEmojiesSuccess(appConfig), diceEmojiesSuccess: buildDiceEmojiesSuccess(appConfig),
aiComposeStyles: buildAiComposeStyles(appConfig),
}; };
return { return {

View File

@ -3,6 +3,7 @@ import type { Entity } from '../../../lib/gramjs/types';
import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils'; import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils';
import type { import type {
ApiComposedMessageWithAI,
ApiFormattedText, ApiFormattedText,
ApiMessageEntity, ApiMessageEntity,
ApiMessageEntityDefault, 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 { return {
type: type as `${ApiMessageEntityDefault['type']}`, type: type as `${ApiMessageEntityDefault['type']}`,
offset, offset,
length, length,
}; };
} }
export function buildApiComposedMessageWithAI(
result: GramJs.messages.ComposedMessageWithAI,
): ApiComposedMessageWithAI {
return {
resultText: buildApiFormattedText(result.resultText),
diffText: result.diffText ? buildApiFormattedText(result.diffText) : undefined,
};
}

View File

@ -452,6 +452,7 @@ export async function fetchCurrentUser() {
export function dispatchErrorUpdate<T extends GramJs.AnyRequest>(err: Error, request: T) { export function dispatchErrorUpdate<T extends GramJs.AnyRequest>(err: Error, request: T) {
const message = err instanceof RPCError ? err.errorMessage : err.message; const message = err instanceof RPCError ? err.errorMessage : err.message;
const isSlowMode = message === 'FLOOD' && ( const isSlowMode = message === 'FLOOD' && (
request instanceof GramJs.messages.SendMessage request instanceof GramJs.messages.SendMessage
|| request instanceof GramJs.messages.SendMedia || request instanceof GramJs.messages.SendMedia

View File

@ -10,6 +10,7 @@ import type {
import type { import type {
ApiAttachment, ApiAttachment,
ApiChat, ApiChat,
ApiComposedMessageWithAI,
ApiError, ApiError,
ApiFormattedText, ApiFormattedText,
ApiGlobalMessageSearchType, ApiGlobalMessageSearchType,
@ -60,7 +61,7 @@ import {
buildApiSponsoredMessageReportResult, buildApiSponsoredMessageReportResult,
buildThreadReadState, buildThreadReadState,
} from '../apiBuilders/chats'; } from '../apiBuilders/chats';
import { buildApiFormattedText } from '../apiBuilders/common'; import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildApiTopicWithState } from '../apiBuilders/forums';
import { import {
buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia, buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia,
@ -126,6 +127,7 @@ type TranslateTextParams = ({
messageIds: number[]; messageIds: number[];
}) & { }) & {
toLanguageCode: string; toLanguageCode: string;
tone?: string;
}; };
type SearchResults = { type SearchResults = {
@ -2379,17 +2381,19 @@ export async function translateText(params: TranslateTextParams) {
let result; let result;
const isMessageTranslation = 'chat' in params; const isMessageTranslation = 'chat' in params;
if (isMessageTranslation) { if (isMessageTranslation) {
const { chat, messageIds, toLanguageCode } = params; const { chat, messageIds, toLanguageCode, tone } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({ result = await invokeRequest(new GramJs.messages.TranslateText({
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
id: messageIds, id: messageIds,
toLang: toLanguageCode, toLang: toLanguageCode,
tone,
})); }));
} else { } else {
const { text, toLanguageCode } = params; const { text, toLanguageCode, tone } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({ result = await invokeRequest(new GramJs.messages.TranslateText({
text: text.map((t) => buildInputTextWithEntities(t)), text: text.map((t) => buildInputTextWithEntities(t)),
toLang: toLanguageCode, toLang: toLanguageCode,
tone,
})); }));
} }
@ -2421,14 +2425,15 @@ export async function translateText(params: TranslateTextParams) {
} }
export async function fetchMessageSummary({ 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({ const result = await invokeRequest(new GramJs.messages.SummarizeText({
peer: buildInputPeer(chat.id, chat.accessHash), peer: buildInputPeer(chat.id, chat.accessHash),
id, id,
toLang: toLanguageCode, toLang: toLanguageCode,
tone,
})); }));
if (!result) return undefined; if (!result) return undefined;
@ -2661,3 +2666,41 @@ export async function fetchPreparedInlineMessage({
export function incrementLocalMessagesCounter() { export function incrementLocalMessagesCounter() {
incrementLocalMessageCounter(); 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' };
}
}

View File

@ -510,7 +510,8 @@ export type ApiMessageEntityDefault = {
`${ApiMessageEntityTypes}`, `${ApiMessageEntityTypes}`,
`${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` | `${ApiMessageEntityTypes.Pre}` | `${ApiMessageEntityTypes.TextUrl}` | `${ApiMessageEntityTypes.MentionName}` |
`${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` | `${ApiMessageEntityTypes.Blockquote}` | `${ApiMessageEntityTypes.CustomEmoji}` |
`${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}` `${ApiMessageEntityTypes.Timestamp}` | `${ApiMessageEntityTypes.FormattedDate}` |
`${ApiMessageEntityTypes.DiffInsert}` | `${ApiMessageEntityTypes.DiffReplace}` | `${ApiMessageEntityTypes.DiffDelete}`
>; >;
offset: number; offset: number;
length: number; length: number;
@ -572,9 +573,28 @@ export type ApiMessageEntityTimestamp = {
timestamp: number; 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 | export type ApiMessageEntity = ApiMessageEntityDefault | ApiMessageEntityPre | ApiMessageEntityTextUrl |
ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp | ApiMessageEntityMentionName | ApiMessageEntityCustomEmoji | ApiMessageEntityBlockquote | ApiMessageEntityTimestamp |
ApiMessageEntityFormattedDate; ApiMessageEntityFormattedDate | ApiMessageEntityDiffInsert | ApiMessageEntityDiffReplace | ApiMessageEntityDiffDelete;
export enum ApiMessageEntityTypes { export enum ApiMessageEntityTypes {
Bold = 'MessageEntityBold', Bold = 'MessageEntityBold',
@ -599,6 +619,9 @@ export enum ApiMessageEntityTypes {
QuoteFocus = 'MessageEntityQuoteFocus', QuoteFocus = 'MessageEntityQuoteFocus',
FormattedDate = 'MessageEntityFormattedDate', FormattedDate = 'MessageEntityFormattedDate',
Unknown = 'MessageEntityUnknown', Unknown = 'MessageEntityUnknown',
DiffInsert = 'MessageEntityDiffInsert',
DiffReplace = 'MessageEntityDiffReplace',
DiffDelete = 'MessageEntityDiffDelete',
} }
export interface ApiFormattedText { export interface ApiFormattedText {
@ -610,6 +633,11 @@ export interface ApiFormattedTextWithEmojiOnlyCount extends ApiFormattedText {
emojiOnlyCount?: number; emojiOnlyCount?: number;
} }
export interface ApiComposedMessageWithAI {
resultText: ApiFormattedText;
diffText?: ApiFormattedText;
}
export type MediaContent = { export type MediaContent = {
text?: ApiFormattedTextWithEmojiOnlyCount; text?: ApiFormattedTextWithEmojiOnlyCount;
photo?: ApiPhoto; photo?: ApiPhoto;

View File

@ -242,6 +242,12 @@ export interface ApiCountryCode extends ApiCountry {
patterns?: string[]; patterns?: string[];
} }
export interface ApiAiComposeStyle {
tone: string;
documentId: string;
title: string;
}
export interface ApiAppConfig { export interface ApiAppConfig {
hash: number; hash: number;
emojiSounds: Record<string, string>; emojiSounds: Record<string, string>;
@ -331,6 +337,7 @@ export interface ApiAppConfig {
value: number; value: number;
frameStart: number; frameStart: number;
}>; }>;
aiComposeStyles?: ApiAiComposeStyle[];
} }
export interface ApiConfig { export interface ApiConfig {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="m27.044 8.987-.824-.824c-.79-.791-1.843-1.227-2.96-1.227s-2.17.435-2.96 1.227l-1.525 1.523-.002.002-.002.003L7.098 21.363a2.52 2.52 0 0 0-.731 1.635l-.19 3.38a2.517 2.517 0 0 0 2.652 2.652l3.378-.19a2.5 2.5 0 0 0 1.637-.733l13.2-13.2a4.19 4.19 0 0 0 0-5.92M12.32 26.582a.36.36 0 0 1-.233.105l-3.38.19a.32.32 0 0 1-.275-.104.35.35 0 0 1-.103-.273l.19-3.38a.36.36 0 0 1 .103-.234l10.912-10.911 3.697 3.696zm13.2-13.199-.765.764-3.696-3.697.763-.763a2.02 2.02 0 0 1 1.436-.595c.542 0 1.052.211 1.436.595l.825.825c.79.791.79 2.08 0 2.871M7.813 17.985a3.65 3.65 0 0 1 3.648-3.648v-.554a3.66 3.66 0 0 1-3.648-3.649h-.639a3.64 3.64 0 0 1-3.639 3.64v.563a3.65 3.65 0 0 1 3.648 3.648zM24.817 21.116h-.64a3.64 3.64 0 0 1-3.638 3.64v.562a3.65 3.65 0 0 1 3.648 3.649h.63a3.65 3.65 0 0 1 3.648-3.649v-.553a3.66 3.66 0 0 1-3.648-3.649M13.762 9.384h.509a2.95 2.95 0 0 1 2.95-2.951v-.448a2.96 2.96 0 0 1-2.95-2.95h-.517a2.943 2.943 0 0 1-2.943 2.942v.456a2.95 2.95 0 0 1 2.95 2.95"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="m28.671 27.086-5.173-5.173a11.48 11.48 0 0 0 2.634-7.347c0-3.09-1.203-5.994-3.388-8.178A11.5 11.5 0 0 0 14.565 3a11.49 11.49 0 0 0-8.177 3.388A11.49 11.49 0 0 0 3 14.566c0 3.09 1.203 5.993 3.388 8.178a11.49 11.49 0 0 0 8.177 3.388c2.713 0 5.283-.93 7.348-2.634l5.174 5.173a1.117 1.117 0 0 0 1.585 0 1.12 1.12 0 0 0 0-1.585M7.973 21.158a9.26 9.26 0 0 1-2.73-6.592c0-2.49.969-4.832 2.73-6.592a9.3 9.3 0 0 1 6.592-2.727c2.388 0 4.776.909 6.594 2.727a9.26 9.26 0 0 1 2.73 6.592c0 2.49-.97 4.831-2.73 6.592-3.636 3.636-9.552 3.635-13.186 0"/><path d="m17.915 10.455-4.58 5.843-2.209-2.207a1.12 1.12 0 1 0-1.584 1.586l2.994 2.995c.532.532 1.41.48 1.874-.113l5.269-6.72a1.12 1.12 0 1 0-1.764-1.384"/></svg>

After

Width:  |  Height:  |  Size: 768 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M12.106 12.97h.652a3.713 3.713 0 0 1 3.713-3.714v-.574a3.72 3.72 0 0 1-3.723-3.723h-.642a3.723 3.723 0 0 1-3.723 3.723v.565a3.734 3.734 0 0 1 3.723 3.722M8.232 12.351h-.52a3.01 3.01 0 0 1-3.01 3.01v.458a3.02 3.02 0 0 1 3.01 3.01h.528a3.003 3.003 0 0 1 3.002-3.002v-.465a3.01 3.01 0 0 1-3.01-3.01M18.522 12.394c-.022-.05-.061-.083-.09-.128-.038-.061-.07-.125-.122-.177-.062-.062-.137-.104-.211-.147-.035-.02-.06-.051-.098-.068q-.006 0-.012-.002a1 1 0 0 0-.276-.06c-.037-.003-.073-.02-.11-.02-.036 0-.071.016-.108.02a1 1 0 0 0-.277.06q-.006 0-.012.002c-.036.016-.06.046-.094.066-.076.043-.152.086-.214.149-.052.052-.084.115-.123.176-.028.045-.067.08-.09.13l-5.39 12.448a1 1 0 0 0 1.835.795l1.644-3.796h5.659l1.643 3.796a1 1 0 0 0 1.316.52 1 1 0 0 0 .52-1.315zm-2.881 7.448 1.963-4.533 1.962 4.533zM26.298 17.673a1 1 0 0 0-1 1v6.567a1 1 0 1 0 2 0v-6.567a1 1 0 0 0-1-1M26.298 13.948a1 1 0 0 0-1 1v.393a1 1 0 1 0 2 0v-.393a1 1 0 0 0-1-1"/></svg>

After

Width:  |  Height:  |  Size: 1009 B

View File

@ -2386,6 +2386,8 @@
"ToDoListErrorChooseTasks" = "Please enter at least one task."; "ToDoListErrorChooseTasks" = "Please enter at least one task.";
"GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**"; "GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**";
"PremiumPreviewTodo" = "Checklists"; "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"; "NativeDownloadFailed" = "Failed to save file to the Downloads folder";
"DescriptionAboutTon" = "Offer TON to submit post suggestions to channels on Telegram."; "DescriptionAboutTon" = "Offer TON to submit post suggestions to channels on Telegram.";
"ButtonTopUpViaFragment" = "Top Up Via Fragment"; "ButtonTopUpViaFragment" = "Top Up Via Fragment";
@ -2814,4 +2816,30 @@
"ReminderSetToast" = "You set up a reminder in **Saved Messages**"; "ReminderSetToast" = "You set up a reminder in **Saved Messages**";
"NoForwardsRequestReject" = "Reject"; "NoForwardsRequestReject" = "Reject";
"NoForwardsRequestAccept" = "Accept"; "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.";

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M11.711 13.73h.765a4.36 4.36 0 0 1 4.358-4.357V8.7a4.37 4.37 0 0 1-4.37-4.37h-.753A4.37 4.37 0 0 1 7.342 8.7v.663a4.38 4.38 0 0 1 4.369 4.369M10.803 16.123a3.01 3.01 0 0 1-3.01-3.01h-.52a3.01 3.01 0 0 1-3.01 3.01v.457a3.02 3.02 0 0 1 3.01 3.01h.528a3.003 3.003 0 0 1 3.002-3.002zM17.991 13.61c-.025-.059-.072-.099-.105-.15-.047-.076-.087-.154-.151-.218-.068-.068-.15-.111-.23-.16-.05-.03-.085-.073-.139-.097l-.018-.003c-.093-.039-.193-.048-.293-.063-.056-.007-.11-.03-.166-.03-.055 0-.11.023-.165.03-.1.015-.2.024-.293.063-.006.002-.012 0-.018.003-.054.024-.09.067-.138.096-.08.05-.163.093-.231.161-.064.064-.104.141-.151.217-.033.052-.08.092-.105.15L10.77 25.194a1.2 1.2 0 0 0 2.203.954l1.46-3.37h4.911l1.46 3.37a1.202 1.202 0 0 0 2.203-.954zm-2.518 6.767 1.416-3.27 1.416 3.27zM25.737 18.359a1.2 1.2 0 0 0-1.2 1.2v6.111a1.2 1.2 0 0 0 2.4 0v-6.11a1.2 1.2 0 0 0-1.2-1.201M25.737 14.493a1.2 1.2 0 0 0-1.2 1.2v.365a1.2 1.2 0 0 0 2.4 0v-.365a1.2 1.2 0 0 0-1.2-1.2" fill="white"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -71,6 +71,9 @@ export { default as HeaderMenuContainer } from '../components/middle/HeaderMenuC
export { default as MiddleSearch } from '../components/middle/search/MiddleSearch'; export { default as MiddleSearch } from '../components/middle/search/MiddleSearch';
export { default as ReactionPicker } from '../components/middle/message/reactions/ReactionPicker'; 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 AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/middle/composer/PollModal'; export { default as PollModal } from '../components/middle/composer/PollModal';
export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal'; export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal';

View File

@ -418,6 +418,28 @@
background: var(--color-background); background: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow); 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 { &.with-story-tweaks {
border-radius: var(--border-radius-default-small); border-radius: var(--border-radius-default-small);
border-bottom-right-radius: 0; border-bottom-right-radius: 0;

View File

@ -119,6 +119,7 @@ import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates
import { processDeepLink } from '../../util/deeplink'; import { processDeepLink } from '../../util/deeplink';
import { tryParseDeepLink } from '../../util/deepLinkParser'; import { tryParseDeepLink } from '../../util/deepLinkParser';
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection'; import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import calcTextLineHeightAndCount from '../../util/element/calcTextLineHeightAndCount';
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
import { isUserId } from '../../util/entities/ids'; import { isUserId } from '../../util/entities/ids';
import { fetchBlob } from '../../util/files'; import { fetchBlob } from '../../util/files';
@ -254,6 +255,7 @@ type StateProps = {
forwardedMessagesCount?: number; forwardedMessagesCount?: number;
pollModal: TabState['pollModal']; pollModal: TabState['pollModal'];
todoListModal: TabState['todoListModal']; todoListModal: TabState['todoListModal'];
aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult'];
botKeyboardMessageId?: number; botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string; botKeyboardPlaceholder?: string;
withScheduledButton?: boolean; withScheduledButton?: boolean;
@ -382,6 +384,7 @@ const Composer = ({
forwardedMessagesCount, forwardedMessagesCount,
pollModal, pollModal,
todoListModal, todoListModal,
aiMessageEditorPendingResult,
botKeyboardMessageId, botKeyboardMessageId,
botKeyboardPlaceholder, botKeyboardPlaceholder,
inputPlaceholder, inputPlaceholder,
@ -457,11 +460,14 @@ const Composer = ({
const { const {
sendMessage, sendMessage,
clearDraft, clearDraft,
saveDraft,
showDialog, showDialog,
openPollModal, openPollModal,
closePollModal, closePollModal,
openTodoListModal, openTodoListModal,
closeTodoListModal, closeTodoListModal,
openAiMessageEditorModal,
clearAiMessageEditorPendingResult,
loadScheduledHistory, loadScheduledHistory,
openThread, openThread,
addRecentEmoji, addRecentEmoji,
@ -859,6 +865,25 @@ const Composer = ({
updateInsertingPeerIdMention({ peerId: undefined }); updateInsertingPeerIdMention({ peerId: undefined });
}, [insertingPeerIdMention, insertMention]); }, [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 { const {
isOpen: isInlineBotTooltipOpen, isOpen: isInlineBotTooltipOpen,
botId: inlineBotId, botId: inlineBotId,
@ -1369,6 +1394,14 @@ const Composer = ({
openTodoListModal({ chatId }); openTodoListModal({ chatId });
}); });
const handleOpenAiEditor = useLastCallback(() => {
const { text, entities } = parseHtmlAsFormattedText(getHtml());
openAiMessageEditorModal({
chatId,
text: { text, entities },
});
});
const handleClickBotMenu = useLastCallback(() => { const handleClickBotMenu = useLastCallback(() => {
if (botMenuButton?.type !== 'webApp') { if (botMenuButton?.type !== 'webApp') {
return; return;
@ -1788,7 +1821,25 @@ const Composer = ({
}; };
}, [isSelectModeActive, enableHover, disableHover, isReady]); }, [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 const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage
&& messageListType === 'thread'; && messageListType === 'thread';
@ -2012,8 +2063,11 @@ const Composer = ({
}); });
const handleSendScheduledAttachments = useLastCallback( 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( handleActionWithPaymentConfirmation(
handleMessageSchedule, handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia }, { sendCompressed, sendGrouped, isInvertedMedia },
@ -2022,7 +2076,18 @@ const Composer = ({
currentMessageList!, currentMessageList!,
undefined, undefined,
); );
}); } else {
requestCalendar((calendarScheduledAt, calendarRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
calendarScheduledAt,
calendarRepeatPeriod,
currentMessageList!,
undefined,
);
});
}
}, },
); );
@ -2191,6 +2256,17 @@ const Composer = ({
</g> </g>
</svg> </svg>
)} )}
<Button
round
faded
className={buildClassName('ai-composer-button', (!shouldShowAiButton
|| hasAttachments) && 'ai-composer-button-hidden')}
color="translucent"
ariaLabel={lang('AiMessageEditor')}
iconName="ai"
tabIndex={shouldShowAiButton && !hasAttachments ? 0 : -1}
onClick={handleOpenAiEditor}
/>
{isInMessageList && ( {isInMessageList && (
<> <>
<InlineBotTooltip <InlineBotTooltip
@ -2739,6 +2815,7 @@ export default memo(withGlobal<OwnProps>(
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined, forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
pollModal: tabState.pollModal, pollModal: tabState.pollModal,
todoListModal: tabState.todoListModal, todoListModal: tabState.todoListModal,
aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult,
stickersForEmoji: global.stickers.forEmoji.stickers, stickersForEmoji: global.stickers.forEmoji.stickers,
customEmojiForEmoji: global.customEmojis.forEmoji.stickers, customEmojiForEmoji: global.customEmojis.forEmoji.stickers,
chatFullInfo, chatFullInfo,
@ -2794,7 +2871,8 @@ export default memo(withGlobal<OwnProps>(
paidMessagesStars, paidMessagesStars,
shouldPaidMessageAutoApprove, shouldPaidMessageAutoApprove,
isSilentPosting, isSilentPosting,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen, isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen
&& !tabState.aiMessageEditorModal,
starsBalance, starsBalance,
isStarsBalanceModalOpen, isStarsBalanceModalOpen,
shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton, shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton,

View File

@ -120,6 +120,7 @@ const RecipientPicker = ({
orderedFolderIds, orderedFolderIds,
chatFoldersById, chatFoldersById,
maxFolders, maxFolders,
noEmoticons: true,
isReadOnly: true, isReadOnly: true,
}); });

View File

@ -685,6 +685,25 @@ function processEntity({
{renderNestedMessagePart()} {renderNestedMessagePart()}
</FormattedDate> </FormattedDate>
); );
case ApiMessageEntityTypes.DiffInsert:
return (
<span className="text-entity-diff-insert" data-entity-type={entity.type}>
{renderNestedMessagePart()}
</span>
);
case ApiMessageEntityTypes.DiffReplace:
return (
<span className="text-entity-diff-replace" data-entity-type={entity.type}>
<span className="text-entity-diff-replace-old">{entity.oldText}</span>
<span className="text-entity-diff-replace-new">{renderNestedMessagePart()}</span>
</span>
);
case ApiMessageEntityTypes.DiffDelete:
return (
<span className="text-entity-diff-delete" data-entity-type={entity.type}>
{renderNestedMessagePart()}
</span>
);
default: default:
return renderNestedMessagePart(); return renderNestedMessagePart();
} }

View File

@ -56,6 +56,7 @@ export const PREMIUM_FEATURE_TITLES: Record<ApiPremiumSection, string> = {
last_seen: 'PremiumPreviewLastSeen', last_seen: 'PremiumPreviewLastSeen',
message_privacy: 'PremiumPreviewMessagePrivacy', message_privacy: 'PremiumPreviewMessagePrivacy',
effects: 'Premium.MessageEffects', effects: 'Premium.MessageEffects',
ai_compose: 'PremiumPreviewAiTools',
todo: 'PremiumPreviewTodo', todo: 'PremiumPreviewTodo',
pm_noforwards: 'PremiumPreviewNoForwards', pm_noforwards: 'PremiumPreviewNoForwards',
}; };
@ -79,6 +80,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<ApiPremiumSection, string> = {
last_seen: 'PremiumPreviewLastSeenDescription', last_seen: 'PremiumPreviewLastSeenDescription',
message_privacy: 'PremiumPreviewMessagePrivacyDescription', message_privacy: 'PremiumPreviewMessagePrivacyDescription',
effects: 'Premium.MessageEffectsInfo', effects: 'Premium.MessageEffectsInfo',
ai_compose: 'PremiumPreviewAiToolsDescription',
todo: 'PremiumPreviewTodoDescription', todo: 'PremiumPreviewTodoDescription',
pm_noforwards: 'PremiumPreviewNoForwardsDescription', pm_noforwards: 'PremiumPreviewNoForwardsDescription',
}; };
@ -310,7 +312,7 @@ const PremiumFeatureModal: FC<OwnProps> = ({
} }
const i = promo.videoSections.indexOf(section); const i = promo.videoSections.indexOf(section);
const shouldUseNewLang = section === 'todo'; const shouldUseNewLang = section === 'todo' || section === 'ai_compose';
return ( return (
<div className={styles.slide}> <div className={styles.slide}>
<div className={styles.frame}> <div className={styles.frame}>
@ -326,7 +328,7 @@ const PremiumFeatureModal: FC<OwnProps> = ({
<h1 className={styles.title}> <h1 className={styles.title}>
{shouldUseNewLang {shouldUseNewLang
? lang( ? lang(
PREMIUM_FEATURE_TITLES['todo'] as keyof LangPair, PREMIUM_FEATURE_TITLES[section] as keyof LangPair,
undefined, undefined,
{ withNodes: true, renderTextFilters: ['br'] }, { withNodes: true, renderTextFilters: ['br'] },
) )
@ -335,7 +337,7 @@ const PremiumFeatureModal: FC<OwnProps> = ({
<div className={styles.description}> <div className={styles.description}>
{renderText(shouldUseNewLang {renderText(shouldUseNewLang
? lang( ? lang(
PREMIUM_FEATURE_DESCRIPTIONS['todo'] as keyof LangPair, PREMIUM_FEATURE_DESCRIPTIONS[section] as keyof LangPair,
undefined, undefined,
{ withNodes: true, renderTextFilters: ['br'] }, { withNodes: true, renderTextFilters: ['br'] },
) )

View File

@ -49,6 +49,7 @@ import PremiumSubscriptionOption from './PremiumSubscriptionOption';
import styles from './PremiumMainModal.module.scss'; import styles from './PremiumMainModal.module.scss';
import PremiumAds from '../../../assets/premium/PremiumAds.svg'; import PremiumAds from '../../../assets/premium/PremiumAds.svg';
import PremiumAi from '../../../assets/premium/PremiumAi.svg';
import PremiumBadge from '../../../assets/premium/PremiumBadge.svg'; import PremiumBadge from '../../../assets/premium/PremiumBadge.svg';
import PremiumChats from '../../../assets/premium/PremiumChats.svg'; import PremiumChats from '../../../assets/premium/PremiumChats.svg';
import PremiumEffects from '../../../assets/premium/PremiumEffects.svg'; import PremiumEffects from '../../../assets/premium/PremiumEffects.svg';
@ -89,6 +90,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record<ApiPremiumSection, string> = {
last_seen: PremiumLastSeen, last_seen: PremiumLastSeen,
message_privacy: PremiumMessagePrivacy, message_privacy: PremiumMessagePrivacy,
effects: PremiumEffects, effects: PremiumEffects,
ai_compose: PremiumAi,
todo: PremiumBadge, todo: PremiumBadge,
pm_noforwards: PremiumNoforwards, pm_noforwards: PremiumNoforwards,
}; };
@ -444,7 +446,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
</div> </div>
<div className={buildClassName(styles.list, isPremium && styles.noButton)}> <div className={buildClassName(styles.list, isPremium && styles.noButton)}>
{filteredSections.map((section, index) => { {filteredSections.map((section, index) => {
const shouldUseNewLang = section === 'todo' || section === 'pm_noforwards'; const shouldUseNewLang = section === 'todo' || section === 'pm_noforwards' || section === 'ai_compose';
return ( return (
<PremiumFeatureItem <PremiumFeatureItem
key={section} key={section}

View File

@ -0,0 +1,132 @@
.labelRow {
display: flex;
gap: 0.25rem;
align-items: center;
margin-bottom: 0.5rem;
}
.label {
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text);
}
.labelTransition {
position: relative;
width: auto;
height: 1.25rem;
}
.labelSlide {
position: absolute;
inset: 0;
}
.separator {
height: 0.0625rem;
margin-block: 0.75rem;
opacity: 0.75;
background: var(--color-borders);
}
.resultArea {
position: relative;
overflow: hidden;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 0.1s;
}
.loadingContainer {
position: absolute;
z-index: 1;
inset: 0;
padding-top: 0.5rem;
opacity: 1;
background: var(--color-background);
transition: opacity 150ms ease-out;
&.hidden {
pointer-events: none;
opacity: 0;
}
}
.resultTransition {
overflow: hidden;
min-height: 0;
&.hidden {
pointer-events: none;
opacity: 0;
transition: opacity 150ms ease-out;
}
}
.resultContent {
font-size: 0.9375rem;
line-height: 1.4;
color: var(--color-text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.optionsRow {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.emojifyCheckbox {
min-height: 1.75rem;
padding: 0.25rem 0.5rem;
padding-inline-start: 0.25rem;
border-radius: 1rem;
}
.emojifyCheckboxControl {
column-gap: 0.5rem;
}
.emojifyCheckboxLabel {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
line-height: 1.25rem;
}
.errorMessage {
margin-top: 0.5rem;
padding-bottom: 0.125rem;
font-size: 0.9375rem;
font-style: italic;
line-height: 1.4;
color: var(--color-text-secondary);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.copyButton {
position: absolute;
right: 0.5rem;
bottom: 0.5rem;
&.hidden {
pointer-events: none;
opacity: 0 !important;
transition: opacity 150ms ease-out;
}
}
.hidden {
pointer-events: none;
opacity: 0;
transition: opacity 150ms ease-out;
}

View File

@ -0,0 +1,140 @@
import type { TeactNode } from '../../../../lib/teact/teact';
import { memo, useLayoutEffect, useRef, useState } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import { requestMeasure } from '../../../../lib/fasterdom/fasterdom';
import buildClassName from '../../../../util/buildClassName';
import { copyTextToClipboard } from '../../../../util/clipboard';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import Button from '../../../ui/Button';
import Link from '../../../ui/Link';
import TextLoadingPlaceholder from '../../../ui/placeholder/TextLoadingPlaceholder';
import Transition from '../../../ui/Transition';
import styles from './AiEditorShared.module.scss';
const MIN_HEIGHT = 100;
const HEIGHT_PADDING = 4;
type AiEditorResultAreaProps = {
isLoading?: boolean;
transitionKey?: number;
className?: string;
children: TeactNode;
};
export const AiEditorResultArea = memo(({
isLoading,
transitionKey,
className,
children,
}: AiEditorResultAreaProps) => {
const contentRef = useRef<HTMLDivElement>();
const [height, setHeight] = useState<number | undefined>(undefined);
useLayoutEffect(() => {
if (isLoading || !contentRef.current) return;
requestMeasure(() => {
if (!contentRef.current) return;
const newHeight = contentRef.current.scrollHeight + HEIGHT_PADDING;
setHeight(newHeight);
});
}, [children, isLoading, transitionKey]);
const displayHeight = height ?? MIN_HEIGHT;
return (
<div
className={buildClassName(styles.resultArea, className)}
style={`height: ${displayHeight}px`}
>
<div className={buildClassName(styles.loadingContainer, !isLoading && styles.hidden)}>
<TextLoadingPlaceholder lines={6} />
</div>
<Transition
name="fade"
activeKey={transitionKey ?? 0}
className={buildClassName(styles.resultTransition, isLoading && styles.hidden)}
>
<div ref={contentRef} className={styles.resultContent}>
{children}
</div>
</Transition>
</div>
);
});
type AiEditorErrorMessageProps = {
error?: 'floodPremium' | 'aiError' | 'generic';
isPremium?: boolean;
};
export const AiEditorErrorMessage = memo(({
error,
isPremium,
}: AiEditorErrorMessageProps) => {
const { openPremiumModal } = getActions();
const lang = useLang();
const handleOpenPremiumModal = useLastCallback(() => {
openPremiumModal({ initialSection: 'ai_compose' });
});
if (!error) return undefined;
const isFloodError = error === 'floodPremium';
return (
<div className={styles.errorMessage}>
{isFloodError ? (
isPremium
? lang('AiMessageEditorDailyLimitReachedPremium')
: lang('AiMessageEditorDailyLimitReached', {
link: (
<Link isPrimary onClick={handleOpenPremiumModal}>
{lang('TelegramPremium')}
</Link>
),
}, { withNodes: true })
) : lang('AiMessageEditorGenericError')}
</div>
);
});
type AiEditorCopyButtonProps = {
textToCopy?: string;
isHidden?: boolean;
className?: string;
};
export const AiEditorCopyButton = memo(({
textToCopy,
isHidden,
className,
}: AiEditorCopyButtonProps) => {
const { showNotification } = getActions();
const lang = useLang();
const handleCopy = useLastCallback(() => {
if (textToCopy) {
copyTextToClipboard(textToCopy);
showNotification({ message: { key: 'TextCopied' } });
}
});
return (
<Button
className={buildClassName(styles.copyButton, isHidden && styles.hidden, className)}
round
size="tiny"
color="translucent-primary"
iconName="copy"
ariaLabel={lang('Copy')}
onClick={handleCopy}
/>
);
});

View File

@ -0,0 +1,14 @@
import type { OwnProps } from './AiMessageEditorModal';
import { Bundles } from '../../../../util/moduleLoader';
import useModuleLoader from '../../../../hooks/useModuleLoader';
const AiMessageEditorModalAsync = (props: OwnProps) => {
const { modal } = props;
const AiMessageEditorModal = useModuleLoader(Bundles.Extra, 'AiMessageEditorModal', !modal);
return AiMessageEditorModal ? <AiMessageEditorModal {...props} /> : undefined;
};
export default AiMessageEditorModalAsync;

View File

@ -0,0 +1,168 @@
.modal {
max-width: 26rem;
}
.modalDialog {
overflow: hidden;
height: min(38rem, 80vh);
background-color: var(--color-background-secondary);
}
.transitionWrapper {
overflow: hidden;
flex-grow: 1;
min-height: 0;
margin-top: 2.5rem;
}
.transition {
overflow: hidden;
height: 100%;
}
.modalContent {
display: flex;
flex-direction: column;
max-height: 70vh;
padding: 0;
}
.tabListWrapper {
position: absolute;
z-index: 2;
width: 100%;
&::after {
pointer-events: none;
content: "";
position: absolute;
z-index: 0;
top: calc(100% - 2rem);
left: 0;
width: calc(100% - 2rem);
height: 3.5rem;
margin-inline: 1rem;
background: linear-gradient(to bottom, var(--color-background-secondary) 0%, transparent 100%);
}
}
.tabList {
position: relative;
z-index: 1;
margin: 0.5rem 1rem;
margin-bottom: 0;
}
.tab {
gap: 0.125rem;
padding: 0.25rem 1.25rem;
font-size: 0.875rem;
}
.tabContent {
overflow: auto;
flex-grow: 1;
min-height: 0;
padding: 1rem;
padding-top: 3.125rem;
}
.editorBlock {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: 100%;
padding: 0.75rem;
border-radius: 1.5rem;
background: var(--color-background);
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.05);
}
.footer {
position: relative;
display: flex;
padding: 0 1rem 1rem;
&::before {
content: "";
position: absolute;
bottom: 100%;
left: 0;
width: calc(100% - 2rem);
height: 1.5rem;
margin-inline: 1rem;
background: linear-gradient(to top, var(--color-background-secondary) 0%, transparent 100%);
}
}
.applyButton {
flex: 1;
margin-inline-end: 0.5rem;
border-radius: 2rem;
}
.sendButton {
overflow: visible;
flex-shrink: 0;
:global(.icon) {
margin-inline-end: 0;
font-size: 1.75rem;
}
}
.paidStarsBadge {
position: absolute;
top: -1rem;
left: 50%;
transform: translateX(-50%);
height: auto;
margin-top: 0.625rem;
padding-block: 0.25rem;
padding-inline: 0.375rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-semibold) !important;
line-height: 1;
&.hidden {
pointer-events: none;
opacity: 0;
}
:global(.icon) {
margin-inline-start: 0;
margin-inline-end: 0.0625rem;
font-size: 0.875rem;
}
}
.paidStarsBadgeText {
display: inline-flex;
align-items: center;
}
.helpButton {
position: absolute;
z-index: 3;
top: 0.875rem;
right: 1.375rem;
}
:global {
.CustomSendMenu {
bottom: 1rem !important;
}
}

View File

@ -0,0 +1,396 @@
import { memo, useEffect, useMemo, useRef } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiChat } from '../../../../api/types';
import type { TabState } from '../../../../global/types';
import type { AnimationLevel } from '../../../../types';
import type { IconName } from '../../../../types/icons';
import { SCHEDULED_WHEN_ONLINE } from '../../../../config';
import { getPeerTitle } from '../../../../global/helpers/peers';
import {
selectCanScheduleUntilOnline,
selectChat,
selectIsChatWithSelf,
selectPeerPaidMessagesStars,
selectTabState,
} from '../../../../global/selectors';
import { selectCurrentMessageList } from '../../../../global/selectors/messages';
import { selectAnimationLevel } from '../../../../global/selectors/sharedState';
import { selectIsCurrentUserPremium } from '../../../../global/selectors/users';
import buildClassName from '../../../../util/buildClassName';
import { resolveTransitionName } from '../../../../util/resolveTransitionName';
import useContextMenuHandlers from '../../../../hooks/useContextMenuHandlers';
import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import useSchedule from '../../../../hooks/useSchedule';
import usePaidMessageConfirmation from '../hooks/usePaidMessageConfirmation';
import AnimatedCounter from '../../../common/AnimatedCounter';
import Icon from '../../../common/icons/Icon';
import PaymentMessageConfirmDialog from '../../../common/PaymentMessageConfirmDialog';
import Button from '../../../ui/Button';
import Modal from '../../../ui/Modal';
import TabList from '../../../ui/TabList';
import Transition from '../../../ui/Transition';
import CustomSendMenu from '../CustomSendMenu.async';
import AiTextFixEditor from './AiTextFixEditor';
import AiTextStyleEditor from './AiTextStyleEditor';
import AiTextTranslateEditor from './AiTextTranslateEditor';
import styles from './AiMessageEditorModal.module.scss';
export type OwnProps = {
modal: TabState['aiMessageEditorModal'];
};
type StateProps = {
animationLevel: AnimationLevel;
isPremium?: boolean;
isChatWithSelf?: boolean;
canScheduleUntilOnline?: boolean;
isInScheduledList?: boolean;
chat?: ApiChat;
paidMessagesStars?: number;
isPaymentMessageConfirmDialogOpen?: boolean;
starsBalance: number;
isStarsBalanceModalOpen?: boolean;
};
const INDEX_TO_TAB_ID = ['translate', 'style', 'fix'] as const;
const TAB_TRANSLATE = 0;
const TAB_STYLE = 1;
const TAB_FIX = 2;
const TAB_ID_TO_INDEX = Object.fromEntries(
INDEX_TO_TAB_ID.map((id, i) => [id, i]),
) as Record<typeof INDEX_TO_TAB_ID[number], number>;
const AiMessageEditorModal = ({
modal,
animationLevel,
isPremium,
isChatWithSelf,
canScheduleUntilOnline,
isInScheduledList,
chat,
paidMessagesStars,
isPaymentMessageConfirmDialogOpen,
starsBalance,
isStarsBalanceModalOpen,
}: OwnProps & StateProps) => {
const {
closeAiMessageEditorModal,
setAiMessageEditorTab,
applyAiMessageEditorResult,
sendAiMessageEditorResult,
composeWithAiMessageEditor,
openCocoonModal,
} = getActions();
const lang = useLang();
const mainButtonRef = useRef<HTMLButtonElement>();
const [requestCalendar, calendar] = useSchedule(canScheduleUntilOnline);
const {
isContextMenuOpen: isCustomSendMenuOpen,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !modal);
const starsForMessage = paidMessagesStars || 0;
const shouldRenderPaidBadge = Boolean(paidMessagesStars);
const {
closeConfirmDialog: closeConfirmModalPayForMessage,
handleWithConfirmation: handleActionWithPaymentConfirmation,
dialogHandler: confirmModalPayForMessageHandler,
shouldAutoApprove: shouldPaidMessageAutoApprove,
setAutoApprove: setShouldPaidMessageAutoApprove,
} = usePaidMessageConfirmation(starsForMessage, Boolean(isStarsBalanceModalOpen), starsBalance, true);
useEffect(() => {
if (!isCustomSendMenuOpen) {
handleContextMenuHide();
handleContextMenuClose();
}
}, [isCustomSendMenuOpen, handleContextMenuHide, handleContextMenuClose]);
const isOpen = Boolean(modal);
const renderingModal = useCurrentOrPrev(modal);
const {
activeTab,
text,
translateTab,
styleTab,
fixTab,
} = renderingModal || {};
const currentTabState = activeTab === 'translate' ? translateTab
: activeTab === 'style' ? styleTab : fixTab;
const isLoading = currentTabState?.isLoading;
const error = currentTabState?.error;
const tabs = useMemo((): { icon: IconName; title: string }[] => [
{ icon: 'language', title: lang('AiMessageEditorTranslate') },
{ icon: 'ai-edit', title: lang('AiMessageEditorStyle') },
{ icon: 'ai-fix', title: lang('AiMessageEditorFix') },
], [lang]);
const activeTabIndex = TAB_ID_TO_INDEX[activeTab || 'style'] ?? TAB_STYLE;
const handleTabChange = useLastCallback((index: number) => {
const tab = INDEX_TO_TAB_ID[index];
setAiMessageEditorTab({ tab });
if (!text?.text) return;
switch (tab) {
case 'translate':
composeWithAiMessageEditor({
translateToLang: translateTab?.selectedLanguage,
changeTone: translateTab?.selectedTone,
isEmojify: translateTab?.shouldEmojify,
});
break;
case 'style':
if (styleTab?.selectedTone) {
composeWithAiMessageEditor({ changeTone: styleTab.selectedTone, isEmojify: styleTab?.shouldEmojify });
}
break;
case 'fix':
composeWithAiMessageEditor({ shouldProofread: true });
break;
}
});
const handleApply = useLastCallback(() => {
applyAiMessageEditorResult();
});
const handleOpenCocoonModal = useLastCallback(() => {
openCocoonModal();
});
const handleSendAction = useLastCallback((
isSilent?: boolean, scheduledAt?: number, scheduleRepeatPeriod?: number,
) => {
sendAiMessageEditorResult({ isSilent, scheduledAt, scheduleRepeatPeriod });
});
const handleSend = useLastCallback(() => {
if (isInScheduledList) {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
sendAiMessageEditorResult({ scheduledAt, scheduleRepeatPeriod });
});
} else {
handleActionWithPaymentConfirmation(handleSendAction);
}
});
const handleSendSilent = useLastCallback(() => {
handleActionWithPaymentConfirmation(handleSendAction, true);
});
const handleSendSchedule = useLastCallback(() => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
sendAiMessageEditorResult({ scheduledAt, scheduleRepeatPeriod });
});
});
const handleSendWhenOnline = useLastCallback(() => {
sendAiMessageEditorResult({ scheduledAt: SCHEDULED_WHEN_ONLINE });
});
function renderTabContent() {
switch (activeTabIndex) {
case TAB_TRANSLATE:
return (
<div className={styles.tabContent}>
<AiTextTranslateEditor
text={text}
selectedLanguage={translateTab?.selectedLanguage}
selectedTone={translateTab?.selectedTone}
shouldEmojify={translateTab?.shouldEmojify}
isLoading={translateTab?.isLoading}
result={translateTab?.result}
error={translateTab?.error}
isPremium={isPremium}
/>
</div>
);
case TAB_STYLE:
return (
<div className={styles.tabContent}>
<AiTextStyleEditor
text={text}
selectedTone={styleTab?.selectedTone}
shouldEmojify={styleTab?.shouldEmojify}
isLoading={styleTab?.isLoading}
result={styleTab?.result}
error={styleTab?.error}
isPremium={isPremium}
/>
</div>
);
case TAB_FIX:
return (
<div className={styles.tabContent}>
<AiTextFixEditor
text={text}
isLoading={fixTab?.isLoading}
result={fixTab?.result}
error={fixTab?.error}
isPremium={isPremium}
/>
</div>
);
default:
return undefined;
}
}
return (
<Modal
isOpen={isOpen}
title={lang('AiMessageEditor')}
hasCloseButton
onClose={closeAiMessageEditorModal}
className={styles.modal}
headerClassName="modal-header-condensed-wide"
dialogClassName={styles.modalDialog}
contentClassName={styles.modalContent}
headerRightToolBar={(
<Button
className={styles.helpButton}
round
size="tiny"
color="translucent"
iconName="help"
ariaLabel={lang('ButtonHelp')}
onClick={handleOpenCocoonModal}
/>
)}
isSlim
>
<div className={styles.tabListWrapper}>
<TabList
tabs={tabs}
activeTab={activeTabIndex}
onSwitchTab={handleTabChange}
className={styles.tabList}
tabClassName={styles.tab}
stretched
itemAlignment="vertical"
/>
</div>
<div className={styles.transitionWrapper}>
<Transition
className={styles.transition}
name={resolveTransitionName('slideOptimized', animationLevel, undefined, lang.isRtl)}
activeKey={activeTabIndex}
renderCount={tabs.length}
>
{renderTabContent()}
</Transition>
</div>
<div className={styles.footer}>
<Button
className={styles.applyButton}
disabled={isLoading || Boolean(error)}
onClick={handleApply}
>
{lang('AiMessageEditorApply')}
</Button>
<Button
ref={mainButtonRef}
className={styles.sendButton}
round
color="primary"
disabled={isLoading || Boolean(error)}
ariaLabel={lang('Send')}
onClick={handleSend}
onContextMenu={!isInScheduledList && !paidMessagesStars ? handleContextMenu : undefined}
iconName="new-send"
>
<Button
className={buildClassName(
styles.paidStarsBadge,
!shouldRenderPaidBadge && styles.hidden,
)}
nonInteractive
size="tiny"
color="stars"
pill
fluid
>
<div className={styles.paidStarsBadgeText}>
<Icon name="star" />
<AnimatedCounter text={lang.number(starsForMessage)} />
</div>
</Button>
</Button>
{isOpen && !isInScheduledList && (
<CustomSendMenu
isOpen={isCustomSendMenuOpen}
canSchedule
canScheduleUntilOnline={canScheduleUntilOnline}
onSendSilent={!isChatWithSelf ? handleSendSilent : undefined}
onSendSchedule={handleSendSchedule}
onSendWhenOnline={handleSendWhenOnline}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
isSavedMessages={isChatWithSelf}
/>
)}
</div>
{calendar}
<PaymentMessageConfirmDialog
isOpen={Boolean(isPaymentMessageConfirmDialogOpen)}
onClose={closeConfirmModalPayForMessage}
userName={chat ? getPeerTitle(lang, chat) : undefined}
messagePriceInStars={paidMessagesStars || 0}
messagesCount={1}
shouldAutoApprove={shouldPaidMessageAutoApprove}
setAutoApprove={setShouldPaidMessageAutoApprove}
confirmHandler={confirmModalPayForMessageHandler}
/>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): Complete<StateProps> => {
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));

View File

@ -0,0 +1,3 @@
.section {
margin-bottom: 0;
}

View File

@ -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 <AiEditorErrorMessage error={error} isPremium={isPremium} />;
}
return displayResult && renderDiffText(displayResult);
}
return (
<div className={modalStyles.editorBlock}>
<div className={styles.section}>
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>
{lang('AiMessageEditorOriginal')}
</span>
</div>
<ExpandableText text={text?.text} />
</div>
<div className={sharedStyles.separator} />
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>{lang('AiMessageEditorResult')}</span>
</div>
<AiEditorResultArea isLoading={isLoading}>
{renderResultText()}
</AiEditorResultArea>
<AiEditorCopyButton
textToCopy={result?.resultText?.text || text?.text}
isHidden={isLoading || hasError || !displayResult?.text}
/>
</div>
);
};
function renderDiffText(formattedText: ApiFormattedText) {
const { text, entities } = formattedText;
return renderTextWithEntities({
text,
entities,
});
}
export default memo(AiTextFixEditor);

View File

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

View File

@ -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 <AiEditorErrorMessage error={error} isPremium={isPremium} />;
}
return (
<div className={styles.previewText}>
{displayText?.text && renderTextWithEntities({
text: displayText.text,
entities: displayText.entities,
})}
</div>
);
}
return (
<div className={buildClassName(modalStyles.editorBlock, styles.styleBlock)}>
<TabList
tabs={styleTabs}
activeTab={activeStyleIndex}
onSwitchTab={handleStyleSelect}
className={styles.tabList}
tabClassName={styles.tab}
indicatorClassName={styles.tabListIndicator}
itemAlignment="vertical"
/>
<div className={sharedStyles.separator} />
<div className={sharedStyles.optionsRow}>
<Transition
name="fade"
activeKey={showResultLabel ? 1 : 0}
className={sharedStyles.labelTransition}
slideClassName={sharedStyles.labelSlide}
>
<span className={showResultLabel ? styles.resultLabel : styles.textLabel}>{displayLabel}</span>
</Transition>
<CheckboxField
className={sharedStyles.emojifyCheckbox}
controlClassName={sharedStyles.emojifyCheckboxControl}
labelClassName={sharedStyles.emojifyCheckboxLabel}
label={lang('AiMessageEditorEmojify')}
checked={Boolean(shouldEmojify)}
isRound
onChange={handleEmojifyChange}
/>
</div>
<AiEditorResultArea isLoading={isLoading} transitionKey={transitionKey}>
{renderPreviewText()}
</AiEditorResultArea>
<AiEditorCopyButton
textToCopy={displayText?.text}
isHidden={isLoading || shouldShowError || !displayText?.text}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
aiComposeStyles: global.appConfig?.aiComposeStyles ?? [],
};
},
)(AiTextStyleEditor));

View File

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

View File

@ -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<IAnchorPosition | undefined>();
const triggerRef = useRef<HTMLSpanElement>();
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 <AiEditorErrorMessage error={error} isPremium={isPremium} />;
}
return displayResult && renderTextWithEntities({
text: displayResult.text,
entities: displayResult.entities,
});
}
return (
<div className={modalStyles.editorBlock}>
<div className={styles.section}>
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>
{lang('AiMessageEditorFrom')}
</span>
<span className={styles.sourceLanguage}>
{detectedLanguageName}
</span>
</div>
<ExpandableText text={text?.text} />
</div>
<div className={sharedStyles.separator} />
<div className={sharedStyles.optionsRow}>
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>
{lang('AiMessageEditorTo')}
</span>
<span
ref={triggerRef}
className={styles.languageLink}
onClick={handleTriggerClick}
>
{selectedLanguageName}
<i className="icon icon-down" />
</span>
<Menu
isOpen={isMenuOpen}
anchor={menuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className={buildClassName('language-menu', 'with-menu-transitions', styles.languageMenu)}
autoClose
onClose={closeMenu}
withPortal
>
<TranslationToneSelector
selectedTone={selectedTone}
onSelectTone={handleToneSelect}
/>
<div className={buildClassName(styles.languageItems, 'custom-scroll')}>
{languages.map(({ langCode, displayName }) => (
<MenuItem
key={langCode}
onClick={() => handleLanguageSelect(langCode)}
>
{displayName}
</MenuItem>
))}
</div>
</Menu>
</div>
<CheckboxField
className={sharedStyles.emojifyCheckbox}
controlClassName={sharedStyles.emojifyCheckboxControl}
labelClassName={sharedStyles.emojifyCheckboxLabel}
label={lang('AiMessageEditorEmojify')}
checked={Boolean(shouldEmojify)}
isRound
onChange={handleEmojifyChange}
/>
</div>
<AiEditorResultArea isLoading={isLoading} transitionKey={transitionKey}>
{renderResultText()}
</AiEditorResultArea>
<AiEditorCopyButton
textToCopy={result?.resultText?.text || text?.text}
isHidden={isLoading || hasError || !displayResult?.text}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
aiComposeStyles: global.appConfig.aiComposeStyles || EMPTY_AI_COMPOSE_STYLES,
};
},
)(AiTextTranslateEditor));

View File

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

View File

@ -42,7 +42,7 @@
} }
.symbol-menu-button, .mobile-symbol-menu-button { .symbol-menu-button, .mobile-symbol-menu-button {
align-self: center; align-self: flex-end;
margin-right: -1.5rem; margin-right: -1.5rem;
margin-left: -0.25rem !important; margin-left: -0.25rem !important;
color: var(--color-composer-button); color: var(--color-composer-button);
@ -146,6 +146,27 @@
align-items: center; 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 { .dropTarget {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
@ -219,6 +240,8 @@
.send-wrapper { .send-wrapper {
position: relative; position: relative;
align-self: flex-end;
margin-bottom: 0.1875rem;
} }
.send { .send {

View File

@ -2,7 +2,7 @@ import { type FC, memo, useEffect, useMemo, useRef, useState } from '../../../li
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiAttachment, ApiChatMember, ApiMessage, ApiSticker } from '../../../api/types'; 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 { MessageListType, ThreadId } from '../../../types';
import type { Signal } from '../../../util/signals'; import type { Signal } from '../../../util/signals';
@ -13,17 +13,20 @@ import {
SUPPORTED_PHOTO_CONTENT_TYPES, SUPPORTED_PHOTO_CONTENT_TYPES,
SUPPORTED_VIDEO_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES,
} from '../../../config'; } from '../../../config';
import { requestMutation } from '../../../lib/fasterdom/fasterdom'; import { requestMeasure, requestMutation } from '../../../lib/fasterdom/fasterdom';
import { getAttachmentMediaType } from '../../../global/helpers'; import { getAttachmentMediaType } from '../../../global/helpers';
import { selectChatFullInfo, selectIsChatWithSelf, selectTabState } from '../../../global/selectors'; import { selectChatFullInfo, selectIsChatWithSelf, selectTabState } from '../../../global/selectors';
import { selectCurrentLimit } from '../../../global/selectors/limits'; import { selectCurrentLimit } from '../../../global/selectors/limits';
import { selectSharedSettings } from '../../../global/selectors/sharedState'; import { selectSharedSettings } from '../../../global/selectors/sharedState';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import captureEscKeyListener from '../../../util/captureEscKeyListener'; import captureEscKeyListener from '../../../util/captureEscKeyListener';
import calcTextLineHeightAndCount from '../../../util/element/calcTextLineHeightAndCount';
import { validateFiles } from '../../../util/files'; import { validateFiles } from '../../../util/files';
import { formatStarsAsIcon } from '../../../util/localization/format'; import { formatStarsAsIcon } from '../../../util/localization/format';
import parseHtmlAsFormattedText from '../../../util/parseHtmlAsFormattedText';
import { removeAllSelections } from '../../../util/selection'; import { removeAllSelections } from '../../../util/selection';
import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog';
import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities';
import buildAttachment from './helpers/buildAttachment'; import buildAttachment from './helpers/buildAttachment';
import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems'; import getFilesFromDataTransferItems from './helpers/getFilesFromDataTransferItems';
import { getHtmlTextLength } from './helpers/getHtmlTextLength'; import { getHtmlTextLength } from './helpers/getHtmlTextLength';
@ -81,7 +84,10 @@ export type OwnProps = {
onAttachmentsUpdate: (attachments: ApiAttachment[]) => void; onAttachmentsUpdate: (attachments: ApiAttachment[]) => void;
onClear: NoneToVoidFunction; onClear: NoneToVoidFunction;
onSendSilent: (sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => void; 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; onCustomEmojiSelect: (emoji: ApiSticker) => void;
onRemoveSymbol: VoidFunction; onRemoveSymbol: VoidFunction;
onEmojiSelect: (emoji: string) => void; onEmojiSelect: (emoji: string) => void;
@ -101,6 +107,7 @@ type StateProps = {
attachmentSettings: GlobalState['attachmentSettings']; attachmentSettings: GlobalState['attachmentSettings'];
shouldSaveAttachmentsCompression?: boolean; shouldSaveAttachmentsCompression?: boolean;
shouldOpenMessageMediaEditor?: boolean; shouldOpenMessageMediaEditor?: boolean;
aiMessageEditorPendingResult?: TabState['aiMessageEditorPendingResult'];
}; };
const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text'; const ATTACHMENT_MODAL_INPUT_ID = 'caption-input-text';
@ -137,6 +144,7 @@ const AttachmentModal = ({
canSchedule, canSchedule,
paidMessagesStars, paidMessagesStars,
shouldOpenMessageMediaEditor, shouldOpenMessageMediaEditor,
aiMessageEditorPendingResult,
onAttachmentsUpdate, onAttachmentsUpdate,
onCaptionUpdate, onCaptionUpdate,
onSend, onSend,
@ -153,7 +161,7 @@ const AttachmentModal = ({
const svgRef = useRef<SVGSVGElement>(); const svgRef = useRef<SVGSVGElement>();
const { const {
addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest, addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings, resetMessageMediaEditorRequest,
updateShouldSaveAttachmentsCompression, updateShouldSaveAttachmentsCompression, openAiMessageEditorModal, clearAiMessageEditorPendingResult,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
@ -174,6 +182,8 @@ const AttachmentModal = ({
const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag(); const [isSymbolMenuOpen, openSymbolMenu, closeSymbolMenu] = useFlag();
const [editingAttachmentIndex, setEditingAttachmentIndex] = useState<number | undefined>(undefined); const [editingAttachmentIndex, setEditingAttachmentIndex] = useState<number | undefined>(undefined);
const [shouldShowAiButton, setShouldShowAiButton] = useState(false);
const html = useDerivedState(() => getHtml(), [getHtml]);
const editingAttachment = editingAttachmentIndex !== undefined const editingAttachment = editingAttachmentIndex !== undefined
? attachments[editingAttachmentIndex] : undefined; ? attachments[editingAttachmentIndex] : undefined;
@ -308,22 +318,69 @@ const AttachmentModal = ({
handleContextMenuHide, handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !canShowCustomSendMenu || !isOpen); } = useContextMenuHandlers(mainButtonRef, !canShowCustomSendMenu || !isOpen);
const sendAttachments = useLastCallback((isSilent?: boolean, shouldSendScheduled?: boolean) => { useEffect(() => {
if (isOpen) { requestMeasure(() => {
const send = ((shouldSchedule || shouldSendScheduled) && isForMessage && !editingMessage) ? onSendScheduled const input = inputRef.current;
: isSilent ? onSendSilent : onSend; if (!html || !input) {
send(isSendingCompressed, shouldSendGrouped, isInvertedMedia); setShouldShowAiButton(false);
updateAttachmentSettings({ return;
...(shouldSaveAttachmentsCompression && { }
defaultAttachmentCompression: attachmentSettings.shouldCompress ? 'compress' : 'original', const { totalLines } = calcTextLineHeightAndCount(input, true);
}), setShouldShowAiButton(totalLines >= 3);
shouldSendGrouped, });
isInvertedMedia, }, [html, isOpen]);
shouldSendInHighQuality,
}); 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(() => { const handleSendSilent = useLastCallback(() => {
sendAttachments(true); sendAttachments(true);
}); });
@ -749,6 +806,16 @@ const AttachmentModal = ({
styles.captionWrapper, styles.captionWrapper,
)} )}
> >
<Button
round
size="tiny"
color="translucent"
tabIndex={shouldShowAiButton ? 0 : -1}
className={buildClassName(styles.aiButton, !shouldShowAiButton && styles.aiButtonHidden)}
onClick={handleOpenAiEditor}
ariaLabel={lang('AiMessageEditor')}
iconName="ai"
/>
<MentionTooltip <MentionTooltip
isOpen={isMentionTooltipOpen} isOpen={isMentionTooltipOpen}
filteredUsers={mentionFilteredUsers} filteredUsers={mentionFilteredUsers}
@ -861,7 +928,11 @@ export default memo(withGlobal<OwnProps>(
attachmentSettings, attachmentSettings,
} = global; } = global;
const { shouldSaveAttachmentsCompression, shouldOpenMessageMediaEditor } = selectTabState(global); const {
shouldSaveAttachmentsCompression,
shouldOpenMessageMediaEditor,
aiMessageEditorPendingResult,
} = selectTabState(global);
const chatFullInfo = selectChatFullInfo(global, chatId); const chatFullInfo = selectChatFullInfo(global, chatId);
const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isChatWithSelf = selectIsChatWithSelf(global, chatId);
const { shouldSuggestCustomEmoji } = global.settings.byKey; const { shouldSuggestCustomEmoji } = global.settings.byKey;
@ -882,6 +953,7 @@ export default memo(withGlobal<OwnProps>(
attachmentSettings, attachmentSettings,
shouldSaveAttachmentsCompression, shouldSaveAttachmentsCompression,
shouldOpenMessageMediaEditor, shouldOpenMessageMediaEditor,
aiMessageEditorPendingResult,
}; };
}, },
)(AttachmentModal)); )(AttachmentModal));

View File

@ -9,6 +9,7 @@ export default function usePaidMessageConfirmation(
starsForAllMessages: number, starsForAllMessages: number,
isStarsBalanceModeOpen: boolean, isStarsBalanceModeOpen: boolean,
starsBalance: number, starsBalance: number,
shouldDelayConfirmHandler?: boolean,
) { ) {
const { const {
shouldPaidMessageAutoApprove, shouldPaidMessageAutoApprove,
@ -45,14 +46,19 @@ export default function usePaidMessageConfirmation(
const dialogHandler = useLastCallback(() => { const dialogHandler = useLastCallback(() => {
if (starsForAllMessages > starsBalance) { if (starsForAllMessages > starsBalance) {
handleStarsTopup(); handleStarsTopup();
} else if (shouldDelayConfirmHandler) {
setTimeout(() => {
confirmPaymentHandlerRef?.current?.();
}, 250);
} else { } else {
confirmPaymentHandlerRef?.current?.(); confirmPaymentHandlerRef?.current?.();
} }
getActions().closePaymentMessageConfirmDialogOpen(); getActions().closePaymentMessageConfirmDialogOpen();
if (shouldAutoApprove) getActions().setPaidMessageAutoApprove(); if (shouldAutoApprove) getActions().setPaidMessageAutoApprove();
}); });
const handleWithConfirmation = <T extends (...args: any[]) => void>( const handleWithConfirmation = useLastCallback(<T extends (...args: any[]) => void>(
handler: T, handler: T,
...args: Parameters<T> ...args: Parameters<T>
) => { ) => {
@ -70,7 +76,7 @@ export default function usePaidMessageConfirmation(
} }
handler(...args); handler(...args);
}; });
return { return {
closeConfirmDialog, closeConfirmDialog,

View File

@ -76,6 +76,13 @@
transform: translateY(calc(-100% - 0.5rem)); transform: translateY(calc(-100% - 0.5rem));
} }
.TranslationToneSelector {
position: absolute;
top: 0;
left: 100%;
transform: translateX(0.5rem);
}
.no-forwards-notice { .no-forwards-notice {
min-width: 12rem; min-width: 12rem;
} }

View File

@ -0,0 +1,57 @@
.root {
min-width: 3rem;
max-width: fit-content;
}
.itemsWrapper {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
padding: 0.1875rem 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(25px);
}
}
.item {
cursor: var(--custom-cursor, pointer);
display: flex;
gap: 0.5rem;
align-items: center;
margin-block: 0.0625rem;
margin-inline: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.5rem;
font-size: 0.9375rem;
white-space: nowrap;
transition: background-color 0.15s ease;
&:hover {
background: var(--color-background-compact-menu-hover);
}
&.selected {
background-color: var(--color-item-active);
}
}
.neutralEmoji {
font-size: 1.25rem;
line-height: 1;
}
.title {
color: var(--color-text);
}

View File

@ -0,0 +1,80 @@
import { memo } from '../../../lib/teact/teact';
import { withGlobal } from '../../../global';
import type { ApiAiComposeStyle } from '../../../api/types';
import buildClassName from '../../../util/buildClassName';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import CustomEmoji from '../../common/CustomEmoji';
import { getStyleTitle } from '../composer/AiMessageEditorModal/helpers';
import styles from './TranslationToneSelector.module.scss';
const EMOJI_SIZE = 20;
const EMPTY_AI_COMPOSE_STYLES: ApiAiComposeStyle[] = [];
type OwnProps = {
selectedTone?: string;
style?: string;
onSelectTone: (tone?: string) => void;
};
type StateProps = {
aiComposeStyles: ApiAiComposeStyle[];
};
const TranslationToneSelector = ({
selectedTone,
style,
aiComposeStyles,
onSelectTone,
}: OwnProps & StateProps) => {
const lang = useLang();
const handleToneClick = useLastCallback((tone?: string) => {
onSelectTone(tone);
});
if (!aiComposeStyles.length) return undefined;
return (
<div className={buildClassName(styles.root, 'TranslationToneSelector')} style={style}>
<div className={styles.itemsWrapper}>
<div
className={buildClassName(styles.item, !selectedTone && styles.selected)}
onClick={() => handleToneClick(undefined)}
>
<span className={styles.neutralEmoji}>🏳</span>
<span className={styles.title}>{lang('TranslationToneNeutral')}</span>
</div>
{aiComposeStyles.map(({ tone, documentId, title }) => (
<div
key={tone}
className={buildClassName(styles.item, selectedTone === tone && styles.selected)}
onClick={() => handleToneClick(tone)}
>
{documentId && (
<CustomEmoji
documentId={documentId}
size={EMOJI_SIZE}
shouldNotLoop
/>
)}
<span className={styles.title}>{getStyleTitle(lang, tone, title)}</span>
</div>
))}
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
aiComposeStyles: global.appConfig.aiComposeStyles || EMPTY_AI_COMPOSE_STYLES,
};
},
)(TranslationToneSelector));

View File

@ -1060,6 +1060,26 @@
} }
} }
.text-entity-diff-insert,
.text-entity-diff-replace-new {
color: var(--color-primary);
}
.text-entity-diff-replace-new {
text-decoration: underline wavy;
}
.text-entity-diff-replace-old {
color: var(--color-error);
text-decoration: line-through;
}
.text-entity-diff-delete {
color: var(--color-error);
text-decoration: line-through;
text-decoration-color: var(--color-error);
}
// Keep this close to `CodeBlock` style to avoid jumps in height // Keep this close to `CodeBlock` style to avoid jumps in height
.text-entity-pre { .text-entity-pre {
position: relative; position: relative;

View File

@ -8,6 +8,7 @@ import { pick } from '../../util/iteratees';
import VerificationMonetizationModal from '../common/VerificationMonetizationModal.async'; import VerificationMonetizationModal from '../common/VerificationMonetizationModal.async';
import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.async'; import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal.async';
import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async';
import AboutAdsModal from './aboutAds/AboutAdsModal.async'; import AboutAdsModal from './aboutAds/AboutAdsModal.async';
import AgeVerificationModal from './ageVerification/AgeVerificationModal.async'; import AgeVerificationModal from './ageVerification/AgeVerificationModal.async';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async'; import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
@ -80,6 +81,7 @@ import WebAppModal from './webApp/WebAppModal.async';
// `Pick` used only to provide tab completion // `Pick` used only to provide tab completion
type ModalKey = keyof Pick<TabState, type ModalKey = keyof Pick<TabState,
'aiMessageEditorModal' |
'giftCodeModal' | 'giftCodeModal' |
'boostModal' | 'boostModal' |
'chatlistModal' | 'chatlistModal' |
@ -166,6 +168,7 @@ type Entries<T> = {
}[keyof T][]; }[keyof T][];
const MODALS: ModalRegistry = { const MODALS: ModalRegistry = {
aiMessageEditorModal: AiMessageEditorModal,
giftCodeModal: GiftCodeModal, giftCodeModal: GiftCodeModal,
boostModal: BoostModal, boostModal: BoostModal,
chatlistModal: ChatlistModal, chatlistModal: ChatlistModal,

View File

@ -195,6 +195,7 @@
} }
} }
&.translucent-primary,
&.translucent { &.translucent {
--ripple-color: var(--color-interactive-element-hover); --ripple-color: var(--color-interactive-element-hover);
@ -245,6 +246,10 @@
} }
} }
&.translucent-primary {
color: var(--color-primary);
}
&.translucent-bordered { &.translucent-bordered {
--ripple-color: rgba(0, 0, 0, 0.08); --ripple-color: rgba(0, 0, 0, 0.08);

View File

@ -25,7 +25,8 @@ export type OwnProps = {
size?: 'default' | 'smaller' | 'tiny'; size?: 'default' | 'smaller' | 'tiny';
color?: ( color?: (
'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black' 'primary' | 'secondary' | 'gray' | 'danger' | 'translucent' | 'translucent-white' | 'translucent-black'
| 'translucent-bordered' | 'dark' | 'green' | 'adaptive' | 'stars' | 'bluredStarsBadge' | 'transparentBlured' | 'translucent-bordered' | 'translucent-primary' | 'dark' | 'green' | 'adaptive' | 'stars' | 'bluredStarsBadge'
| 'transparentBlured'
); );
backgroundImage?: string; backgroundImage?: string;
id?: string; id?: string;

View File

@ -55,7 +55,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 14px; font-size: 0.875rem;
} }
} }
} }

View File

@ -19,6 +19,7 @@ type OwnProps = {
positionY?: 'top' | 'bottom'; positionY?: 'top' | 'bottom';
footer?: string; footer?: string;
forceOpen?: boolean; forceOpen?: boolean;
withPortal?: boolean;
onOpen?: NoneToVoidFunction; onOpen?: NoneToVoidFunction;
onClose?: NoneToVoidFunction; onClose?: NoneToVoidFunction;
onHide?: NoneToVoidFunction; onHide?: NoneToVoidFunction;
@ -39,6 +40,7 @@ const DropdownMenu: FC<OwnProps> = ({
positionY = 'top', positionY = 'top',
footer, footer,
forceOpen, forceOpen,
withPortal,
onOpen, onOpen,
onClose, onClose,
onTransitionEnd, onTransitionEnd,
@ -114,6 +116,7 @@ const DropdownMenu: FC<OwnProps> = ({
positionY={positionY} positionY={positionY}
footer={footer} footer={footer}
autoClose={autoClose} autoClose={autoClose}
withPortal={withPortal}
onClose={handleClose} onClose={handleClose}
onCloseAnimationEnd={onHide} onCloseAnimationEnd={onHide}
onMouseEnterBackdrop={onMouseEnterBackdrop} onMouseEnterBackdrop={onMouseEnterBackdrop}

View File

@ -0,0 +1,54 @@
.root {
position: relative;
font-size: 0.9375rem;
line-height: 1.4;
color: var(--color-text);
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.inner {
overflow: hidden;
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 0.15s;
}
.content {
display: block;
}
.moreLinkWrapper {
display: block;
text-align: right;
}
.truncated .moreLinkWrapper {
position: absolute;
right: 0;
bottom: 0.25rem;
padding-inline-start: 1.5rem;
background: linear-gradient(to right, transparent, var(--color-background) 30%);
}
.moreLink {
cursor: pointer;
display: inline-flex;
align-items: center;
padding-inline: 0.5rem;
border-radius: 0.75rem;
font-size: 0.9375rem;
color: var(--color-primary);
transition: background-color 0.15s;
&:hover {
background-color: var(--color-primary-opacity);
}
}

View File

@ -0,0 +1,79 @@
import { memo, useEffect, useRef, useState } from '../../lib/teact/teact';
import { requestMeasure } from '../../lib/fasterdom/fasterdom';
import buildClassName from '../../util/buildClassName';
import { REM } from '../common/helpers/mediaDimensions';
import useFlag from '../../hooks/useFlag';
import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver';
import styles from './ExpandableText.module.scss';
const DEFAULT_COLLAPSED_HEIGHT = 4.1875 * REM;
type OwnProps = {
text?: string;
collapsedHeight?: number;
className?: string;
};
const ExpandableText = ({ text, collapsedHeight = DEFAULT_COLLAPSED_HEIGHT, className }: OwnProps) => {
const lang = useLang();
const [isExpanded, expand, collapse] = useFlag(false);
const [expandedHeight, setExpandedHeight] = useState<number | undefined>();
const [shouldTruncate, setShouldTruncate] = useState(false);
const contentRef = useRef<HTMLSpanElement>();
const displayText = text || '';
const canExpand = shouldTruncate;
const measureTruncation = useLastCallback(() => {
requestMeasure(() => {
if (!contentRef.current) return;
setShouldTruncate(contentRef.current.scrollHeight > collapsedHeight);
});
});
useEffect(() => {
measureTruncation();
}, [displayText, collapsedHeight, measureTruncation]);
useResizeObserver(contentRef, measureTruncation);
const handleToggleExpand = useLastCallback(() => {
if (isExpanded) {
collapse();
} else {
if (contentRef.current) {
setExpandedHeight(contentRef.current.scrollHeight);
}
expand();
}
});
return (
<div className={buildClassName(styles.root, !isExpanded && canExpand && styles.truncated, className)}>
<div
className={styles.inner}
style={isExpanded && expandedHeight
? `height: ${expandedHeight}px`
: `height: ${collapsedHeight / REM}rem`}
>
<span ref={contentRef} className={styles.content}>{displayText}</span>
</div>
{canExpand && (
<span className={styles.moreLinkWrapper}>
<span className={styles.moreLink} onClick={handleToggleExpand}>
{lang(isExpanded ? 'TextShowLess' : 'TextShowMore')}
</span>
</span>
)}
</div>
);
};
export default memo(ExpandableText);

View File

@ -2,6 +2,7 @@ import type { ElementRef, TeactNode } from '../../lib/teact/teact';
import { memo, useEffect, useRef } from '../../lib/teact/teact'; import { memo, useEffect, useRef } from '../../lib/teact/teact';
import type { ApiMessageEntityCustomEmoji } from '../../api/types'; import type { ApiMessageEntityCustomEmoji } from '../../api/types';
import type { IconName } from '../../types/icons';
import type { MenuItemContextAction } from './ListItem'; import type { MenuItemContextAction } from './ListItem';
import animateHorizontalScroll from '../../util/animateHorizontalScroll'; import animateHorizontalScroll from '../../util/animateHorizontalScroll';
@ -19,6 +20,7 @@ import './SquareTabList.scss';
export type TabWithProperties = { export type TabWithProperties = {
id?: number; id?: number;
title: TeactNode; title: TeactNode;
icon?: IconName;
badgeCount?: number; badgeCount?: number;
isBlocked?: boolean; isBlocked?: boolean;
isBadgeActive?: boolean; isBadgeActive?: boolean;

View File

@ -11,6 +11,8 @@
} }
.container { .container {
--tab-radius: 1.25rem;
user-select: none; user-select: none;
scrollbar-width: none; scrollbar-width: none;
@ -33,6 +35,16 @@
&.ready { &.ready {
opacity: 1; opacity: 1;
} }
&.vertical {
--tab-radius: 1.75rem;
border-radius: 1.875rem;
}
}
.centered {
justify-content: center;
} }
.activeIndicator { .activeIndicator {
@ -54,6 +66,10 @@
background-color: var(--color-primary-opacity); background-color: var(--color-primary-opacity);
transition: clip-path var(--slide-transition); transition: clip-path var(--slide-transition);
&.stretched {
width: 100%;
}
} }
.tab { .tab {
@ -65,7 +81,7 @@
align-items: center; align-items: center;
padding: 0.375rem 1rem; padding: 0.375rem 1rem;
border-radius: 1.25rem; border-radius: var(--tab-radius);
font-size: 1rem; font-size: 1rem;
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@ -79,6 +95,23 @@
.activeIndicator & { .activeIndicator & {
color: var(--color-primary); color: var(--color-primary);
} }
&.vertical {
flex-direction: column;
}
&.stretched {
flex: 1;
justify-content: center;
}
}
.tabEmoji,
.tabIcon {
--custom-emoji-size: 1.25rem;
font-size: 1.25rem;
line-height: 1.25rem;
} }
.lockIcon { .lockIcon {

View File

@ -10,14 +10,22 @@ import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver'; import useResizeObserver from '../../hooks/useResizeObserver';
import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon'; import Icon from '../common/icons/Icon';
import styles from './TabList.module.scss'; import styles from './TabList.module.scss';
const EMOJI_SIZE = 20;
type OwnProps = { type OwnProps = {
tabs: readonly TabWithProperties[]; tabs: readonly TabWithProperties[];
activeTab: number; activeTab: number;
className?: string; className?: string;
tabClassName?: string;
indicatorClassName?: string;
centered?: boolean;
stretched?: boolean;
itemAlignment?: 'vertical' | 'horizontal';
onSwitchTab: (index: number) => void; onSwitchTab: (index: number) => void;
}; };
@ -25,6 +33,11 @@ const TabList = ({
tabs, tabs,
activeTab, activeTab,
className, className,
tabClassName,
indicatorClassName,
centered,
stretched,
itemAlignment,
onSwitchTab, onSwitchTab,
}: OwnProps) => { }: OwnProps) => {
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
@ -43,7 +56,9 @@ const TabList = ({
const left = (offsetLeft / containerWidth * 100).toFixed(1); const left = (offsetLeft / containerWidth * 100).toFixed(1);
const right = ((containerWidth - (offsetLeft + offsetWidth)) / containerWidth * 100).toFixed(1); const right = ((containerWidth - (offsetLeft + offsetWidth)) / containerWidth * 100).toFixed(1);
setClipPath(`inset(0.25rem ${right}% 0.25rem ${left}% round 1.25rem)`); setClipPath(`inset(0.25rem ${right}% 0.25rem ${left}% round var(--tab-radius))`);
} else if (activeTab < 0) {
setClipPath('inset(0 100% 0 100%)');
} }
}); });
@ -59,27 +74,56 @@ const TabList = ({
if (!tabs.length) return undefined; if (!tabs.length) return undefined;
const renderTab = (tab: TabWithProperties, index: number) => ( const renderTab = (tab: TabWithProperties, index: number) => {
<div const stringEmoticon = typeof tab.emoticon === 'string' ? tab.emoticon : undefined;
key={tab.id ?? index} const customEmoji = typeof tab.emoticon === 'object' ? tab.emoticon : undefined;
className={styles.tab}
onClick={() => handleTabClick(index)} return (
> <div
{tab.title} key={tab.id ?? index}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />} className={buildClassName(
</div> styles.tab,
); tabClassName,
itemAlignment === 'vertical' && styles.vertical,
stretched && styles.stretched,
)}
onClick={() => handleTabClick(index)}
>
{stringEmoticon && <span className={styles.tabEmoji}>{stringEmoticon}</span>}
{customEmoji && (
<CustomEmoji
documentId={customEmoji.documentId}
className={styles.tabEmoji}
size={EMOJI_SIZE}
shouldNotLoop
/>
)}
{tab.icon && <Icon name={tab.icon} className={styles.tabIcon} />}
{tab.title}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div>
);
};
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className={buildClassName(styles.container, className, clipPath && styles.ready)} className={buildClassName(
styles.container,
centered && styles.centered,
itemAlignment === 'vertical' && styles.vertical,
className,
clipPath && styles.ready,
)}
> >
{tabs.map(renderTab)} {tabs.map(renderTab)}
<div <div
ref={clipPathContainerRef} ref={clipPathContainerRef}
className={styles.activeIndicator} className={buildClassName(styles.activeIndicator,
centered && styles.centered,
stretched && styles.stretched,
indicatorClassName)}
style={clipPath ? `clip-path: ${clipPath}` : undefined} style={clipPath ? `clip-path: ${clipPath}` : undefined}
aria-hidden aria-hidden
> >

View File

@ -74,6 +74,30 @@
} }
} }
&.fast-wave::before {
content: "";
position: absolute;
display: block;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, var(--color-skeleton-foreground) 50%, transparent 100%);
animation: skeleton-fast-wave 1.5s ease-in-out infinite;
@keyframes skeleton-fast-wave {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
}
:global(body.in-background) &::before { :global(body.in-background) &::before {
animation-play-state: paused; animation-play-state: paused;
} }

View File

@ -8,12 +8,13 @@ import './Skeleton.scss';
type OwnProps = { type OwnProps = {
variant?: 'rectangular' | 'rounded-rect' | 'round'; variant?: 'rectangular' | 'rounded-rect' | 'round';
animation?: 'wave' | 'pulse' | 'none'; animation?: 'wave' | 'fast-wave' | 'pulse' | 'none';
width?: number; width?: number;
height?: number; height?: number;
forceAspectRatio?: boolean; forceAspectRatio?: boolean;
inline?: boolean; inline?: boolean;
className?: string; className?: string;
style?: string;
}; };
const Skeleton: FC<OwnProps> = ({ const Skeleton: FC<OwnProps> = ({
@ -24,16 +25,18 @@ const Skeleton: FC<OwnProps> = ({
forceAspectRatio, forceAspectRatio,
inline, inline,
className, className,
style,
}) => { }) => {
const classNames = buildClassName('Skeleton', variant, animation, className, inline && 'inline'); const classNames = buildClassName('Skeleton', variant, animation, className, inline && 'inline');
const aspectRatio = (width && height) ? `aspect-ratio: ${width}/${height}` : undefined; const aspectRatio = (width && height) ? `aspect-ratio: ${width}/${height}` : undefined;
const style = buildStyle( const computedStyle = buildStyle(
style,
forceAspectRatio && aspectRatio, forceAspectRatio && aspectRatio,
Boolean(width) && `width: ${width}px`, Boolean(width) && `width: ${width}px`,
!forceAspectRatio && Boolean(height) && `height: ${height}px`, !forceAspectRatio && Boolean(height) && `height: ${height}px`,
); );
return ( return (
<div className={classNames} style={style}>{inline && '\u00A0'}</div> <div className={classNames} style={computedStyle}>{inline && '\u00A0'}</div>
); );
}; };

View File

@ -0,0 +1,10 @@
.root {
display: flex;
flex-direction: column;
gap: 0.8125rem;
}
.line {
height: 0.5rem;
border-radius: 0.125rem;
}

View File

@ -0,0 +1,45 @@
import { memo, useMemo } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import Skeleton from './Skeleton';
import styles from './TextLoadingPlaceholder.module.scss';
type OwnProps = {
lines?: number;
className?: string;
};
const WIDTH_PATTERNS = [
[85, 90, 87, 83, 87, 70],
[81, 75, 83, 87, 81, 93],
[73, 87, 91, 75, 87, 91],
];
const TextLoadingPlaceholder = ({
lines = 6,
className,
}: OwnProps) => {
const lineWidths = useMemo(() => {
const patternIndex = Math.floor(Math.random() * WIDTH_PATTERNS.length);
const pattern = WIDTH_PATTERNS[patternIndex];
return Array.from({ length: lines }, (_, i) => pattern[i % pattern.length]);
}, [lines]);
return (
<div className={buildClassName(styles.root, className)}>
{lineWidths.map((width, i) => (
<Skeleton
key={i}
variant="rounded-rect"
animation="fast-wave"
className={styles.line}
style={`width: ${width}%`}
/>
))}
</div>
);
};
export default memo(TextLoadingPlaceholder);

View File

@ -416,6 +416,7 @@ export const PREMIUM_FEATURE_SECTIONS = [
'last_seen', 'last_seen',
'message_privacy', 'message_privacy',
'effects', 'effects',
'ai_compose',
'todo', 'todo',
'pm_noforwards', 'pm_noforwards',
] as const; ] as const;

View File

@ -7,6 +7,7 @@ import './api/middleSearch';
import './api/management'; import './api/management';
import './api/sync'; import './api/sync';
import './api/accounts'; import './api/accounts';
import './api/ai';
import './api/users'; import './api/users';
import './api/bots'; import './api/bots';
import './api/settings'; import './api/settings';
@ -24,6 +25,7 @@ import './ui/globalSearch';
import './ui/middleSearch'; import './ui/middleSearch';
import './ui/stickerSearch'; import './ui/stickerSearch';
import './ui/account'; import './ui/account';
import './ui/aiMessageEditor';
import './ui/users'; import './ui/users';
import './ui/settings'; import './ui/settings';
import './ui/misc'; import './ui/misc';

View File

@ -0,0 +1,144 @@
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors';
function buildStyleCacheKey(tone: string | undefined, emojify: boolean | undefined) {
return `${tone || ''}_${emojify ? '1' : '0'}`;
}
function buildTranslateCacheKey(lang: string | undefined, tone: string | undefined, emojify: boolean | undefined) {
return `${lang || ''}_${tone || ''}_${emojify ? '1' : '0'}`;
}
addActionHandler('composeWithAiMessageEditor', async (global, actions, payload): Promise<void> => {
const {
shouldProofread, isEmojify, translateToLang, changeTone,
tabId = getCurrentTabId(),
} = payload;
let modal = selectTabState(global, tabId).aiMessageEditorModal;
if (!modal) return;
let cachedResult;
let tabKey: 'translateTab' | 'styleTab' | 'fixTab';
if (shouldProofread) {
tabKey = 'fixTab';
cachedResult = modal.fixTab?.cache;
} else if (translateToLang) {
tabKey = 'translateTab';
const cacheKey = buildTranslateCacheKey(translateToLang, changeTone, isEmojify);
cachedResult = modal.translateTab?.cache?.[cacheKey];
} else {
tabKey = 'styleTab';
const cacheKey = buildStyleCacheKey(changeTone, isEmojify);
cachedResult = modal.styleTab?.cache?.[cacheKey];
}
if (cachedResult) {
global = getGlobal();
modal = selectTabState(global, tabId).aiMessageEditorModal;
if (!modal) return;
global = updateTabState(global, {
aiMessageEditorModal: {
...modal,
[tabKey]: { ...modal[tabKey], result: cachedResult, error: undefined, isLoading: false },
},
}, tabId);
setGlobal(global);
return;
}
global = getGlobal();
modal = selectTabState(global, tabId).aiMessageEditorModal;
if (!modal) return;
global = updateTabState(global, {
aiMessageEditorModal: {
...modal,
[tabKey]: { ...modal[tabKey], isLoading: true },
},
}, tabId);
setGlobal(global);
const response = await callApi('composeMessageWithAI', {
text: modal.text,
shouldProofread,
isEmojify,
translateToLang,
changeTone,
});
global = getGlobal();
modal = selectTabState(global, tabId).aiMessageEditorModal;
if (!modal) return;
if (response?.error) {
global = updateTabState(global, {
aiMessageEditorModal: {
...modal,
[tabKey]: { ...modal[tabKey], result: undefined, isLoading: false, error: response.error },
},
}, tabId);
setGlobal(global);
return;
}
const result = response?.result;
const currentTabState = modal[tabKey] || {};
let isOutdatedResult = false;
if (translateToLang) {
const { selectedLanguage, selectedTone, shouldEmojify } = modal.translateTab || {};
isOutdatedResult = selectedLanguage !== translateToLang
|| selectedTone !== changeTone
|| Boolean(shouldEmojify) !== Boolean(isEmojify);
} else if (!shouldProofread) {
const { selectedTone, shouldEmojify } = modal.styleTab || {};
isOutdatedResult = selectedTone !== changeTone
|| Boolean(shouldEmojify) !== Boolean(isEmojify);
}
let updatedCache;
if (result) {
if (shouldProofread) {
updatedCache = result;
} else if (translateToLang) {
const cacheKey = buildTranslateCacheKey(translateToLang, changeTone, isEmojify);
updatedCache = { ...currentTabState.cache, [cacheKey]: result };
} else {
const cacheKey = buildStyleCacheKey(changeTone, isEmojify);
updatedCache = { ...currentTabState.cache, [cacheKey]: result };
}
}
if (isOutdatedResult) {
global = updateTabState(global, {
aiMessageEditorModal: {
...modal,
[tabKey]: {
...currentTabState,
isLoading: false,
cache: updatedCache !== undefined ? updatedCache : currentTabState.cache,
},
},
}, tabId);
setGlobal(global);
return;
}
global = updateTabState(global, {
aiMessageEditorModal: {
...modal,
[tabKey]: {
...currentTabState,
isLoading: false,
result,
error: undefined,
cache: updatedCache !== undefined ? updatedCache : currentTabState.cache,
},
},
}, tabId);
setGlobal(global);
});

View File

@ -0,0 +1,189 @@
import type { ActionReturnType } from '../../types';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
import { addActionHandler, getActions } from '../../index';
import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors';
import { selectCurrentMessageList } from '../../selectors/messages';
import { selectTranslationLanguage } from '../../selectors/settings';
addActionHandler('openAiMessageEditorModal', (global, actions, payload): ActionReturnType => {
const {
chatId, text, initialTab = 'style', isFromAttachment,
tabId = getCurrentTabId(),
} = payload;
const defaultTranslationLanguage = selectTranslationLanguage(global);
return updateTabState(global, {
aiMessageEditorModal: {
chatId,
text,
activeTab: initialTab,
isFromAttachment,
translateTab: {
selectedLanguage: defaultTranslationLanguage,
},
},
}, tabId);
});
addActionHandler('closeAiMessageEditorModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aiMessageEditorModal: undefined,
}, tabId);
});
addActionHandler('setAiMessageEditorTab', (global, actions, payload): ActionReturnType => {
const { tab, tabId = getCurrentTabId() } = payload;
const aiMessageEditorModal = selectTabState(global, tabId).aiMessageEditorModal;
if (!aiMessageEditorModal) return undefined;
return updateTabState(global, {
aiMessageEditorModal: {
...aiMessageEditorModal,
activeTab: tab,
},
}, tabId);
});
addActionHandler('setAiMessageEditorTranslateOptions', (global, actions, payload): ActionReturnType => {
const {
selectedLanguage, shouldEmojify, clearResult,
tabId = getCurrentTabId(),
} = payload;
const hasSelectedTone = 'selectedTone' in payload;
const aiMessageEditorModal = selectTabState(global, tabId).aiMessageEditorModal;
if (!aiMessageEditorModal) return undefined;
const translateTab = aiMessageEditorModal.translateTab || {};
return updateTabState(global, {
aiMessageEditorModal: {
...aiMessageEditorModal,
translateTab: {
...translateTab,
selectedLanguage: selectedLanguage !== undefined ? selectedLanguage : translateTab.selectedLanguage,
selectedTone: hasSelectedTone ? payload.selectedTone : translateTab.selectedTone,
shouldEmojify: shouldEmojify !== undefined ? shouldEmojify : translateTab.shouldEmojify,
result: clearResult ? undefined : translateTab.result,
error: clearResult ? undefined : translateTab.error,
},
},
}, tabId);
});
addActionHandler('setAiMessageEditorStyleOptions', (global, actions, payload): ActionReturnType => {
const {
shouldEmojify, clearResult,
tabId = getCurrentTabId(),
} = payload;
const hasSelectedTone = 'selectedTone' in payload;
const aiMessageEditorModal = selectTabState(global, tabId).aiMessageEditorModal;
if (!aiMessageEditorModal) return undefined;
const styleTab = aiMessageEditorModal.styleTab || {};
return updateTabState(global, {
aiMessageEditorModal: {
...aiMessageEditorModal,
styleTab: {
...styleTab,
selectedTone: hasSelectedTone ? payload.selectedTone : styleTab.selectedTone,
shouldEmojify: shouldEmojify !== undefined ? shouldEmojify : styleTab.shouldEmojify,
result: clearResult ? undefined : styleTab.result,
error: clearResult ? undefined : styleTab.error,
},
},
}, tabId);
});
addActionHandler('applyAiMessageEditorResult', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
const aiMessageEditorModal = selectTabState(global, tabId).aiMessageEditorModal;
if (!aiMessageEditorModal) return undefined;
const { activeTab } = aiMessageEditorModal;
const tabState = activeTab === 'translate' ? aiMessageEditorModal.translateTab
: activeTab === 'style' ? aiMessageEditorModal.styleTab : aiMessageEditorModal.fixTab;
const textToApply = tabState?.result?.resultText || aiMessageEditorModal.text;
return updateTabState(global, {
aiMessageEditorModal: undefined,
aiMessageEditorPendingResult: {
text: textToApply,
},
}, tabId);
});
addActionHandler('sendAiMessageEditorResult', (global, actions, payload): ActionReturnType => {
const {
isSilent, scheduledAt, scheduleRepeatPeriod,
tabId = getCurrentTabId(),
} = payload || {};
const aiMessageEditorModal = selectTabState(global, tabId).aiMessageEditorModal;
if (!aiMessageEditorModal) return undefined;
const { activeTab, isFromAttachment } = aiMessageEditorModal;
const tabState = activeTab === 'translate' ? aiMessageEditorModal.translateTab
: activeTab === 'style' ? aiMessageEditorModal.styleTab : aiMessageEditorModal.fixTab;
const textToSend = tabState?.result?.resultText || aiMessageEditorModal.text;
if (isFromAttachment) {
return updateTabState(global, {
aiMessageEditorModal: undefined,
aiMessageEditorPendingResult: {
text: textToSend,
shouldSendWithAttachments: true,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
},
}, tabId);
}
const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) return undefined;
const { chatId, threadId } = currentMessageList;
const messageList = scheduledAt
? { ...currentMessageList, type: 'scheduled' as const }
: currentMessageList;
getActions().sendMessage({
messageList,
text: textToSend.text,
entities: textToSend.entities,
isSilent,
scheduledAt,
scheduleRepeatPeriod,
tabId,
});
getActions().clearDraft({ chatId, threadId, isLocalOnly: true });
return updateTabState(global, {
aiMessageEditorModal: undefined,
aiMessageEditorPendingResult: {
shouldClear: true,
},
}, tabId);
});
addActionHandler('clearAiMessageEditorPendingResult', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aiMessageEditorPendingResult: undefined,
}, tabId);
});

View File

@ -2600,6 +2600,41 @@ export interface ActionPayloads {
} & WithTabId) | undefined; } & WithTabId) | undefined;
closePremiumModal: WithTabId | undefined; closePremiumModal: WithTabId | undefined;
openAiMessageEditorModal: {
chatId: string;
text: ApiFormattedText;
initialTab?: 'translate' | 'style' | 'fix';
isFromAttachment?: boolean;
} & WithTabId;
closeAiMessageEditorModal: WithTabId | undefined;
setAiMessageEditorTab: {
tab: 'translate' | 'style' | 'fix';
} & WithTabId;
setAiMessageEditorTranslateOptions: {
selectedLanguage?: string;
selectedTone?: string;
shouldEmojify?: boolean;
clearResult?: boolean;
} & WithTabId;
setAiMessageEditorStyleOptions: {
selectedTone?: string;
shouldEmojify?: boolean;
clearResult?: boolean;
} & WithTabId;
composeWithAiMessageEditor: {
shouldProofread?: boolean;
isEmojify?: boolean;
translateToLang?: string;
changeTone?: string;
} & WithTabId;
applyAiMessageEditorResult: WithTabId | undefined;
sendAiMessageEditorResult: ({
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
} & WithTabId) | undefined;
clearAiMessageEditorPendingResult: WithTabId | undefined;
openGiveawayModal: ({ openGiveawayModal: ({
chatId: string; chatId: string;
gifts?: number[] | undefined; gifts?: number[] | undefined;

View File

@ -10,6 +10,7 @@ import type {
ApiChatType, ApiChatType,
ApiCheckedGiftCode, ApiCheckedGiftCode,
ApiCollectibleInfo, ApiCollectibleInfo,
ApiComposedMessageWithAI,
ApiDialog, ApiDialog,
ApiEmojiStatusCollectible, ApiEmojiStatusCollectible,
ApiFormattedText, ApiFormattedText,
@ -107,6 +108,12 @@ export type PollVote = {
date: number; date: number;
}; };
export type AiEditorTabBase = {
isLoading?: boolean;
result?: ApiComposedMessageWithAI;
error?: 'floodPremium' | 'aiError' | 'generic';
};
export type TabState = { export type TabState = {
id: number; id: number;
isBlurred?: boolean; isBlurred?: boolean;
@ -662,6 +669,36 @@ export type TabState = {
gift?: ApiStarGift; gift?: ApiStarGift;
}; };
aiMessageEditorModal?: {
chatId: string;
text: ApiFormattedText;
activeTab: 'translate' | 'style' | 'fix';
isFromAttachment?: boolean;
translateTab?: AiEditorTabBase & {
selectedLanguage?: string;
selectedTone?: string;
shouldEmojify?: boolean;
cache?: Record<string, ApiComposedMessageWithAI>;
};
styleTab?: AiEditorTabBase & {
selectedTone?: string;
shouldEmojify?: boolean;
cache?: Record<string, ApiComposedMessageWithAI>;
};
fixTab?: AiEditorTabBase & {
cache?: ApiComposedMessageWithAI;
};
};
aiMessageEditorPendingResult?: {
text?: ApiFormattedText;
shouldClear?: boolean;
shouldSendWithAttachments?: boolean;
isSilent?: boolean;
scheduledAt?: number;
scheduleRepeatPeriod?: number;
};
giveawayModal?: { giveawayModal?: {
chatId: string; chatId: string;
isOpen?: boolean; isOpen?: boolean;

View File

@ -33,6 +33,7 @@ type Params = {
orderedFolderIds?: number[]; orderedFolderIds?: number[];
chatFoldersById: Record<number, ApiChatFolder>; chatFoldersById: Record<number, ApiChatFolder>;
maxFolders: number; maxFolders: number;
noEmoticons?: boolean;
} & ({ } & ({
isReadOnly?: false; isReadOnly?: false;
maxChatLists: number; maxChatLists: number;
@ -48,6 +49,7 @@ const useFolderTabs = (params: Params) => {
orderedFolderIds, orderedFolderIds,
chatFoldersById, chatFoldersById,
maxFolders, maxFolders,
noEmoticons,
isReadOnly, isReadOnly,
} = params; } = params;
@ -236,13 +238,13 @@ const useFolderTabs = (params: Params) => {
isBadgeActive: Boolean(folderCountersById[id]?.notificationsCount), isBadgeActive: Boolean(folderCountersById[id]?.notificationsCount),
isBlocked, isBlocked,
contextActions: contextActions.length ? contextActions : undefined, contextActions: contextActions.length ? contextActions : undefined,
emoticon: folderIcon, emoticon: noEmoticons ? undefined : folderIcon,
noTitleAnimations: folder.noTitleAnimations, noTitleAnimations: folder.noTitleAnimations,
} satisfies TabWithProperties; } satisfies TabWithProperties;
}); });
}, [ }, [
displayedFolders, maxFolders, folderCountersById, lang, chatFoldersById, maxChatLists, folderInvitesById, displayedFolders, maxFolders, folderCountersById, lang, chatFoldersById, maxChatLists, folderInvitesById,
maxFolderInvites, folderUnreadChatsCountersById, isReadOnly, sidebarMode, isMobile, maxFolderInvites, folderUnreadChatsCountersById, isReadOnly, sidebarMode, isMobile, noEmoticons,
]); ]);
return { return {

View File

@ -1811,6 +1811,7 @@ messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User;
messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates; messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates;
messages.declineUrlAuth#35436bbc url:string = Bool; messages.declineUrlAuth#35436bbc url:string = Bool;
messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool; messages.checkUrlAuthMatchCode#c9a47b0b url:string match_code:string = Bool;
messages.composeMessageWithAI#fd426afe flags:# proofread:flags.0?true emojify:flags.3?true text:TextWithEntities translate_to_lang:flags.1?string change_tone:flags.2?string = messages.ComposedMessageWithAI;
updates.getState#edd4882a = updates.State; updates.getState#edd4882a = updates.State;
updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference; updates.getDifference#19c2f763 flags:# pts:int pts_limit:flags.1?int pts_total_limit:flags.0?int date:int qts:int qts_limit:flags.2?int = updates.Difference;
updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference;

View File

@ -204,6 +204,7 @@
"messages.getAvailableEffects", "messages.getAvailableEffects",
"messages.setDefaultReaction", "messages.setDefaultReaction",
"messages.translateText", "messages.translateText",
"messages.composeMessageWithAI",
"messages.getAttachMenuBots", "messages.getAttachMenuBots",
"messages.getAttachMenuBot", "messages.getAttachMenuBot",
"messages.toggleBotInAttachMenu", "messages.toggleBotInAttachMenu",

View File

@ -164,4 +164,5 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
passkeysMaxCount: 5, passkeysMaxCount: 5,
diceEmojies: [], diceEmojies: [],
diceEmojiesSuccess: {}, diceEmojiesSuccess: {},
aiComposeStyles: [],
}; };

View File

@ -3,8 +3,8 @@
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
src: url("./icons.woff2?711696a367cfec0ddfb1ac8f13f32988") format("woff2"), src: url("./icons.woff2?58724e9b0f0ff4defb438785e40e9668") format("woff2"),
url("./icons.woff?711696a367cfec0ddfb1ac8f13f32988") format("woff"); url("./icons.woff?58724e9b0f0ff4defb438785e40e9668") format("woff");
} }
.icon-char::before { .icon-char::before {
@ -906,108 +906,117 @@ url("./icons.woff?711696a367cfec0ddfb1ac8f13f32988") format("woff");
.icon-allow-share::before { .icon-allow-share::before {
content: "\f227"; content: "\f227";
} }
.icon-admin::before { .icon-ai::before {
content: "\f228"; content: "\f228";
} }
.icon-add::before { .icon-ai-fix::before {
content: "\f229"; content: "\f229";
} }
.icon-add-user::before { .icon-ai-edit::before {
content: "\f22a"; content: "\f22a";
} }
.icon-add-user-filled::before { .icon-admin::before {
content: "\f22b"; content: "\f22b";
} }
.icon-add-one-badge::before { .icon-add::before {
content: "\f22c"; content: "\f22c";
} }
.icon-add-filled::before { .icon-add-user::before {
content: "\f22d"; content: "\f22d";
} }
.icon-add-caption::before { .icon-add-user-filled::before {
content: "\f22e"; content: "\f22e";
} }
.icon-active-sessions::before { .icon-add-one-badge::before {
content: "\f22f"; content: "\f22f";
} }
.icon-rating-icons-negative::before { .icon-add-filled::before {
content: "\f230"; content: "\f230";
} }
.icon-rating-icons-level90::before { .icon-add-caption::before {
content: "\f231"; content: "\f231";
} }
.icon-rating-icons-level9::before { .icon-active-sessions::before {
content: "\f232"; content: "\f232";
} }
.icon-rating-icons-level80::before { .icon-rating-icons-negative::before {
content: "\f233"; content: "\f233";
} }
.icon-rating-icons-level8::before { .icon-rating-icons-level90::before {
content: "\f234"; content: "\f234";
} }
.icon-rating-icons-level70::before { .icon-rating-icons-level9::before {
content: "\f235"; content: "\f235";
} }
.icon-rating-icons-level7::before { .icon-rating-icons-level80::before {
content: "\f236"; content: "\f236";
} }
.icon-rating-icons-level60::before { .icon-rating-icons-level8::before {
content: "\f237"; content: "\f237";
} }
.icon-rating-icons-level6::before { .icon-rating-icons-level70::before {
content: "\f238"; content: "\f238";
} }
.icon-rating-icons-level50::before { .icon-rating-icons-level7::before {
content: "\f239"; content: "\f239";
} }
.icon-rating-icons-level5::before { .icon-rating-icons-level60::before {
content: "\f23a"; content: "\f23a";
} }
.icon-rating-icons-level40::before { .icon-rating-icons-level6::before {
content: "\f23b"; content: "\f23b";
} }
.icon-rating-icons-level4::before { .icon-rating-icons-level50::before {
content: "\f23c"; content: "\f23c";
} }
.icon-rating-icons-level30::before { .icon-rating-icons-level5::before {
content: "\f23d"; content: "\f23d";
} }
.icon-rating-icons-level3::before { .icon-rating-icons-level40::before {
content: "\f23e"; content: "\f23e";
} }
.icon-rating-icons-level20::before { .icon-rating-icons-level4::before {
content: "\f23f"; content: "\f23f";
} }
.icon-rating-icons-level2::before { .icon-rating-icons-level30::before {
content: "\f240"; content: "\f240";
} }
.icon-rating-icons-level10::before { .icon-rating-icons-level3::before {
content: "\f241"; content: "\f241";
} }
.icon-rating-icons-level1::before { .icon-rating-icons-level20::before {
content: "\f242"; content: "\f242";
} }
.icon-folder-tabs-user::before { .icon-rating-icons-level2::before {
content: "\f243"; content: "\f243";
} }
.icon-folder-tabs-star::before { .icon-rating-icons-level10::before {
content: "\f244"; content: "\f244";
} }
.icon-folder-tabs-group::before { .icon-rating-icons-level1::before {
content: "\f245"; content: "\f245";
} }
.icon-folder-tabs-folder::before { .icon-folder-tabs-user::before {
content: "\f246"; content: "\f246";
} }
.icon-folder-tabs-chats::before { .icon-folder-tabs-star::before {
content: "\f247"; content: "\f247";
} }
.icon-folder-tabs-chat::before { .icon-folder-tabs-group::before {
content: "\f248"; content: "\f248";
} }
.icon-folder-tabs-channel::before { .icon-folder-tabs-folder::before {
content: "\f249"; content: "\f249";
} }
.icon-folder-tabs-bot::before { .icon-folder-tabs-chats::before {
content: "\f24a"; content: "\f24a";
} }
.icon-folder-tabs-chat::before {
content: "\f24b";
}
.icon-folder-tabs-channel::before {
content: "\f24c";
}
.icon-folder-tabs-bot::before {
content: "\f24d";
}

View File

@ -311,39 +311,42 @@ $icons-map: (
"animals": "\f225", "animals": "\f225",
"allow-speak": "\f226", "allow-speak": "\f226",
"allow-share": "\f227", "allow-share": "\f227",
"admin": "\f228", "ai": "\f228",
"add": "\f229", "ai-fix": "\f229",
"add-user": "\f22a", "ai-edit": "\f22a",
"add-user-filled": "\f22b", "admin": "\f22b",
"add-one-badge": "\f22c", "add": "\f22c",
"add-filled": "\f22d", "add-user": "\f22d",
"add-caption": "\f22e", "add-user-filled": "\f22e",
"active-sessions": "\f22f", "add-one-badge": "\f22f",
"rating-icons-negative": "\f230", "add-filled": "\f230",
"rating-icons-level90": "\f231", "add-caption": "\f231",
"rating-icons-level9": "\f232", "active-sessions": "\f232",
"rating-icons-level80": "\f233", "rating-icons-negative": "\f233",
"rating-icons-level8": "\f234", "rating-icons-level90": "\f234",
"rating-icons-level70": "\f235", "rating-icons-level9": "\f235",
"rating-icons-level7": "\f236", "rating-icons-level80": "\f236",
"rating-icons-level60": "\f237", "rating-icons-level8": "\f237",
"rating-icons-level6": "\f238", "rating-icons-level70": "\f238",
"rating-icons-level50": "\f239", "rating-icons-level7": "\f239",
"rating-icons-level5": "\f23a", "rating-icons-level60": "\f23a",
"rating-icons-level40": "\f23b", "rating-icons-level6": "\f23b",
"rating-icons-level4": "\f23c", "rating-icons-level50": "\f23c",
"rating-icons-level30": "\f23d", "rating-icons-level5": "\f23d",
"rating-icons-level3": "\f23e", "rating-icons-level40": "\f23e",
"rating-icons-level20": "\f23f", "rating-icons-level4": "\f23f",
"rating-icons-level2": "\f240", "rating-icons-level30": "\f240",
"rating-icons-level10": "\f241", "rating-icons-level3": "\f241",
"rating-icons-level1": "\f242", "rating-icons-level20": "\f242",
"folder-tabs-user": "\f243", "rating-icons-level2": "\f243",
"folder-tabs-star": "\f244", "rating-icons-level10": "\f244",
"folder-tabs-group": "\f245", "rating-icons-level1": "\f245",
"folder-tabs-folder": "\f246", "folder-tabs-user": "\f246",
"folder-tabs-chats": "\f247", "folder-tabs-star": "\f247",
"folder-tabs-chat": "\f248", "folder-tabs-group": "\f248",
"folder-tabs-channel": "\f249", "folder-tabs-folder": "\f249",
"folder-tabs-bot": "\f24a", "folder-tabs-chats": "\f24a",
"folder-tabs-chat": "\f24b",
"folder-tabs-channel": "\f24c",
"folder-tabs-bot": "\f24d",
); );

Binary file not shown.

Binary file not shown.

View File

@ -294,6 +294,9 @@ export type FontIconName =
| 'animals' | 'animals'
| 'allow-speak' | 'allow-speak'
| 'allow-share' | 'allow-share'
| 'ai'
| 'ai-fix'
| 'ai-edit'
| 'admin' | 'admin'
| 'add' | 'add'
| 'add-user' | 'add-user'

View File

@ -1772,6 +1772,8 @@ export interface LangPair {
'ToDoListErrorChooseTitle': undefined; 'ToDoListErrorChooseTitle': undefined;
'ToDoListErrorChooseTasks': undefined; 'ToDoListErrorChooseTasks': undefined;
'PremiumPreviewTodo': undefined; 'PremiumPreviewTodo': undefined;
'PremiumPreviewAiTools': undefined;
'PremiumPreviewAiToolsDescription': undefined;
'NativeDownloadFailed': undefined; 'NativeDownloadFailed': undefined;
'DescriptionAboutTon': undefined; 'DescriptionAboutTon': undefined;
'ButtonTopUpViaFragment': undefined; 'ButtonTopUpViaFragment': undefined;
@ -2065,6 +2067,31 @@ export interface LangPair {
'ReminderSetToast': undefined; 'ReminderSetToast': undefined;
'NoForwardsRequestReject': undefined; 'NoForwardsRequestReject': undefined;
'NoForwardsRequestAccept': undefined; 'NoForwardsRequestAccept': undefined;
'AiMessageEditor': undefined;
'AiMessageEditorTranslate': undefined;
'AiMessageEditorStyle': undefined;
'AiMessageEditorFix': undefined;
'AiMessageEditorSelectStyle': undefined;
'AiMessageEditorDailyLimitReachedPremium': undefined;
'AiMessageEditorGenericError': undefined;
'AiMessageEditorStyleFormal': undefined;
'AiMessageEditorStyleShort': undefined;
'AiMessageEditorStyleTribal': undefined;
'AiMessageEditorStyleCorp': undefined;
'AiMessageEditorStyleBiblical': undefined;
'AiMessageEditorStyleViking': undefined;
'AiMessageEditorStyleZen': undefined;
'AiMessageEditorResult': undefined;
'AiMessageEditorOriginal': undefined;
'AiMessageEditorApply': undefined;
'AiMessageEditorEmojify': undefined;
'AiMessageEditorTranslation': undefined;
'TextShowMore': undefined;
'TextShowLess': undefined;
'AiMessageEditorFrom': undefined;
'AiMessageEditorTo': undefined;
'TranslationToneNeutral': undefined;
'ButtonHelp': undefined;
} }
export interface LangPairWithVariables<V = LangVariable> { export interface LangPairWithVariables<V = LangVariable> {
@ -3625,6 +3652,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'RankEditText': { 'RankEditText': {
'user': V; 'user': V;
}; };
'AiMessageEditorDailyLimitReached': {
'link': V;
};
'UnofficialSecurityRisk': { 'UnofficialSecurityRisk': {
'peer': V; 'peer': V;
}; };

View File

@ -1,7 +1,22 @@
export default function calcTextLineHeightAndCount(textContainer: HTMLElement) { export default function calcTextLineHeightAndCount(
const lineHeight = parseInt(getComputedStyle(textContainer).lineHeight, 10); textContainer: HTMLElement,
shouldSubtractPadding?: boolean,
) {
const DEFAULT_LINE_HEIGHT_RATIO = 1.2; // CSS spec default for line-height: normal
const totalLines = textContainer.scrollHeight / lineHeight; const style = getComputedStyle(textContainer);
const lineHeight = style.lineHeight === 'normal'
? parseFloat(style.fontSize) * DEFAULT_LINE_HEIGHT_RATIO
: parseFloat(style.lineHeight);
let contentHeight = textContainer.scrollHeight;
if (shouldSubtractPadding) {
const paddingTop = parseFloat(style.paddingTop) || 0;
const paddingBottom = parseFloat(style.paddingBottom) || 0;
contentHeight -= paddingTop + paddingBottom;
}
const totalLines = Math.round(contentHeight / lineHeight);
return { return {
totalLines, totalLines,

View File

@ -259,6 +259,15 @@ function getEntityDataFromNode(
}; };
} }
if (type === ApiMessageEntityTypes.DiffInsert
|| type === ApiMessageEntityTypes.DiffReplace
|| type === ApiMessageEntityTypes.DiffDelete) {
return {
index,
entity: undefined,
};
}
return { return {
index, index,
entity: { entity: {