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[];
settings_display_passkeys?: boolean;
passkeys_account_passkeys_max?: number;
ai_compose_styles?: [string, string, string][];
}
function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -161,6 +162,11 @@ function buildDiceEmojiesSuccess(appConfig: GramJsAppConfig) {
}, {} as ApiAppConfig['diceEmojiesSuccess']) : {};
}
function buildAiComposeStyles(appConfig: GramJsAppConfig) {
const { ai_compose_styles } = appConfig;
return ai_compose_styles?.map(([tone, documentId, title]) => ({ tone, documentId, title }));
}
function getLimit(appConfig: GramJsAppConfig, key: Limit, fallbackKey: ApiLimitType) {
const defaultLimit = appConfig[`${key}_default`] || DEFAULT_LIMITS[fallbackKey][0];
const premiumLimit = appConfig[`${key}_premium`] || DEFAULT_LIMITS[fallbackKey][1];
@ -272,6 +278,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
passkeysMaxCount: appConfig.passkeys_account_passkeys_max,
diceEmojies: appConfig.emojies_send_dice,
diceEmojiesSuccess: buildDiceEmojiesSuccess(appConfig),
aiComposeStyles: buildAiComposeStyles(appConfig),
};
return {

View File

@ -3,6 +3,7 @@ import type { Entity } from '../../../lib/gramjs/types';
import { strippedPhotoToJpg } from '../../../lib/gramjs/Utils';
import type {
ApiComposedMessageWithAI,
ApiFormattedText,
ApiMessageEntity,
ApiMessageEntityDefault,
@ -301,9 +302,43 @@ export function buildApiMessageEntity(entity: GramJs.TypeMessageEntity): ApiMess
};
}
if (entity instanceof GramJs.MessageEntityDiffInsert) {
return {
type: ApiMessageEntityTypes.DiffInsert,
offset,
length,
};
}
if (entity instanceof GramJs.MessageEntityDiffReplace) {
return {
type: ApiMessageEntityTypes.DiffReplace,
offset,
length,
oldText: entity.oldText,
};
}
if (entity instanceof GramJs.MessageEntityDiffDelete) {
return {
type: ApiMessageEntityTypes.DiffDelete,
offset,
length,
};
}
return {
type: type as `${ApiMessageEntityDefault['type']}`,
offset,
length,
};
}
export function buildApiComposedMessageWithAI(
result: GramJs.messages.ComposedMessageWithAI,
): ApiComposedMessageWithAI {
return {
resultText: buildApiFormattedText(result.resultText),
diffText: result.diffText ? buildApiFormattedText(result.diffText) : undefined,
};
}

View File

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

View File

@ -10,6 +10,7 @@ import type {
import type {
ApiAttachment,
ApiChat,
ApiComposedMessageWithAI,
ApiError,
ApiFormattedText,
ApiGlobalMessageSearchType,
@ -60,7 +61,7 @@ import {
buildApiSponsoredMessageReportResult,
buildThreadReadState,
} from '../apiBuilders/chats';
import { buildApiFormattedText } from '../apiBuilders/common';
import { buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums';
import {
buildMessageMediaContent, buildMessageTextContent, buildPollFromMedia,
@ -126,6 +127,7 @@ type TranslateTextParams = ({
messageIds: number[];
}) & {
toLanguageCode: string;
tone?: string;
};
type SearchResults = {
@ -2379,17 +2381,19 @@ export async function translateText(params: TranslateTextParams) {
let result;
const isMessageTranslation = 'chat' in params;
if (isMessageTranslation) {
const { chat, messageIds, toLanguageCode } = params;
const { chat, messageIds, toLanguageCode, tone } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({
peer: buildInputPeer(chat.id, chat.accessHash),
id: messageIds,
toLang: toLanguageCode,
tone,
}));
} else {
const { text, toLanguageCode } = params;
const { text, toLanguageCode, tone } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({
text: text.map((t) => buildInputTextWithEntities(t)),
toLang: toLanguageCode,
tone,
}));
}
@ -2421,14 +2425,15 @@ export async function translateText(params: TranslateTextParams) {
}
export async function fetchMessageSummary({
chat, id, toLanguageCode,
chat, id, toLanguageCode, tone,
}: {
chat: ApiChat; id: number; toLanguageCode?: string;
chat: ApiChat; id: number; toLanguageCode?: string; tone?: string;
}) {
const result = await invokeRequest(new GramJs.messages.SummarizeText({
peer: buildInputPeer(chat.id, chat.accessHash),
id,
toLang: toLanguageCode,
tone,
}));
if (!result) return undefined;
@ -2661,3 +2666,41 @@ export async function fetchPreparedInlineMessage({
export function incrementLocalMessagesCounter() {
incrementLocalMessageCounter();
}
export async function composeMessageWithAI({
text,
shouldProofread,
isEmojify,
translateToLang,
changeTone,
}: {
text: ApiFormattedText;
shouldProofread?: boolean;
isEmojify?: boolean;
translateToLang?: string;
changeTone?: string;
}): Promise<{ result?: ApiComposedMessageWithAI; error?: 'floodPremium' | 'aiError' | 'generic' }> {
try {
const result = await invokeRequest(new GramJs.messages.ComposeMessageWithAI({
text: buildInputTextWithEntities(text),
proofread: shouldProofread || undefined,
emojify: isEmojify || undefined,
translateToLang,
changeTone,
}), { shouldThrow: true });
if (!result) return { error: 'generic' };
return { result: buildApiComposedMessageWithAI(result) };
} catch (err) {
if (err instanceof RPCError) {
if (err.errorMessage === 'AICOMPOSE_FLOOD_PREMIUM') {
return { error: 'floodPremium' };
}
if (err.errorMessage === 'AICOMPOSE_ERROR_OCCURED') {
return { error: 'aiError' };
}
}
return { error: 'generic' };
}
}

View File

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

View File

@ -242,6 +242,12 @@ export interface ApiCountryCode extends ApiCountry {
patterns?: string[];
}
export interface ApiAiComposeStyle {
tone: string;
documentId: string;
title: string;
}
export interface ApiAppConfig {
hash: number;
emojiSounds: Record<string, string>;
@ -331,6 +337,7 @@ export interface ApiAppConfig {
value: number;
frameStart: number;
}>;
aiComposeStyles?: ApiAiComposeStyle[];
}
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.";
"GiftInfoCollectibleBy" = "Collectible #{number} by **{owner}**";
"PremiumPreviewTodo" = "Checklists";
"PremiumPreviewAiTools" = "AI Tools";
"PremiumPreviewAiToolsDescription" = "Transform your messages and entire chats in your preferred style and language.";
"NativeDownloadFailed" = "Failed to save file to the Downloads folder";
"DescriptionAboutTon" = "Offer TON to submit post suggestions to channels on Telegram.";
"ButtonTopUpViaFragment" = "Top Up Via Fragment";
@ -2814,4 +2816,30 @@
"ReminderSetToast" = "You set up a reminder in **Saved Messages**";
"NoForwardsRequestReject" = "Reject";
"NoForwardsRequestAccept" = "Accept";
"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure."
"AiMessageEditor" = "AI Editor";
"AiMessageEditorTranslate" = "Translate";
"AiMessageEditorStyle" = "Style";
"AiMessageEditorFix" = "Fix";
"AiMessageEditorSelectStyle" = "Select Style";
"AiMessageEditorDailyLimitReached" = "Daily limit reached. To increase limits, get {link}.";
"AiMessageEditorDailyLimitReachedPremium" = "Daily limit reached.";
"AiMessageEditorGenericError" = "Please try again later.";
"AiMessageEditorStyleFormal" = "Formal";
"AiMessageEditorStyleShort" = "Short";
"AiMessageEditorStyleTribal" = "Tribal";
"AiMessageEditorStyleCorp" = "Corp";
"AiMessageEditorStyleBiblical" = "Biblical";
"AiMessageEditorStyleViking" = "Viking";
"AiMessageEditorStyleZen" = "Zen";
"AiMessageEditorResult" = "Result";
"AiMessageEditorOriginal" = "Original";
"AiMessageEditorApply" = "Apply";
"AiMessageEditorEmojify" = "emojify";
"AiMessageEditorTranslation" = "Translation";
"TextShowMore" = "more";
"TextShowLess" = "less";
"AiMessageEditorFrom" = "From";
"AiMessageEditorTo" = "To";
"TranslationToneNeutral" = "Neutral";
"ButtonHelp" = "Help";
"UnofficialSecurityRisk" = "{peer} uses an unofficial Telegram client — messages to this user may be less secure.";

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 ReactionPicker } from '../components/middle/message/reactions/ReactionPicker';
export { default as AiMessageEditorModal }
from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal';
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/middle/composer/PollModal';
export { default as ToDoListModal } from '../components/middle/composer/ToDoListModal';

View File

@ -418,6 +418,28 @@
background: var(--color-background);
box-shadow: 0 1px 2px var(--color-default-shadow);
.ai-composer-button {
position: absolute;
z-index: 1;
top: 0.4375rem;
right: 0.625rem;
width: 1.75rem;
height: 1.75rem;
color: var(--color-composer-button);
opacity: 1;
transition: opacity 150ms ease-out;
}
.ai-composer-button-hidden {
pointer-events: none;
opacity: 0;
transition: opacity 150ms ease-out;
}
&.with-story-tweaks {
border-radius: var(--border-radius-default-small);
border-bottom-right-radius: 0;

View File

@ -119,6 +119,7 @@ import { formatMediaDuration, formatVoiceRecordDuration } from '../../util/dates
import { processDeepLink } from '../../util/deeplink';
import { tryParseDeepLink } from '../../util/deepLinkParser';
import deleteLastCharacterOutsideSelection from '../../util/deleteLastCharacterOutsideSelection';
import calcTextLineHeightAndCount from '../../util/element/calcTextLineHeightAndCount';
import { processMessageInputForCustomEmoji } from '../../util/emoji/customEmojiManager';
import { isUserId } from '../../util/entities/ids';
import { fetchBlob } from '../../util/files';
@ -254,6 +255,7 @@ type StateProps = {
forwardedMessagesCount?: number;
pollModal: TabState['pollModal'];
todoListModal: TabState['todoListModal'];
aiMessageEditorPendingResult: TabState['aiMessageEditorPendingResult'];
botKeyboardMessageId?: number;
botKeyboardPlaceholder?: string;
withScheduledButton?: boolean;
@ -382,6 +384,7 @@ const Composer = ({
forwardedMessagesCount,
pollModal,
todoListModal,
aiMessageEditorPendingResult,
botKeyboardMessageId,
botKeyboardPlaceholder,
inputPlaceholder,
@ -457,11 +460,14 @@ const Composer = ({
const {
sendMessage,
clearDraft,
saveDraft,
showDialog,
openPollModal,
closePollModal,
openTodoListModal,
closeTodoListModal,
openAiMessageEditorModal,
clearAiMessageEditorPendingResult,
loadScheduledHistory,
openThread,
addRecentEmoji,
@ -859,6 +865,25 @@ const Composer = ({
updateInsertingPeerIdMention({ peerId: undefined });
}, [insertingPeerIdMention, insertMention]);
useEffect(() => {
if (!aiMessageEditorPendingResult) return;
const { text, shouldClear, shouldSendWithAttachments } = aiMessageEditorPendingResult;
if (shouldSendWithAttachments) return;
if (shouldClear) {
setHtml('');
clearDraft({ chatId, threadId, isLocalOnly: true });
} else if (text) {
setHtml(getTextWithEntitiesAsHtml(text));
saveDraft({ chatId, threadId, text });
}
clearAiMessageEditorPendingResult();
}, [aiMessageEditorPendingResult, chatId, clearDraft,
clearAiMessageEditorPendingResult, saveDraft, setHtml, threadId]);
const {
isOpen: isInlineBotTooltipOpen,
botId: inlineBotId,
@ -1369,6 +1394,14 @@ const Composer = ({
openTodoListModal({ chatId });
});
const handleOpenAiEditor = useLastCallback(() => {
const { text, entities } = parseHtmlAsFormattedText(getHtml());
openAiMessageEditorModal({
chatId,
text: { text, entities },
});
});
const handleClickBotMenu = useLastCallback(() => {
if (botMenuButton?.type !== 'webApp') {
return;
@ -1788,7 +1821,25 @@ const Composer = ({
};
}, [isSelectModeActive, enableHover, disableHover, isReady]);
const hasText = useDerivedState(() => Boolean(getHtml()), [getHtml]);
const html = useDerivedState(() => getHtml(), [getHtml]);
const hasText = Boolean(html);
const [shouldShowAiButton, setShouldShowAiButton] = useState(false);
useEffect(() => {
if (hasAttachments) {
return;
}
requestMeasure(() => {
const input = inputRef.current;
if (!html || !input) {
setShouldShowAiButton(false);
return;
}
const { totalLines } = calcTextLineHeightAndCount(input, true);
setShouldShowAiButton(totalLines >= 3);
});
}, [html, hasAttachments]);
const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage
&& messageListType === 'thread';
@ -2012,8 +2063,11 @@ const Composer = ({
});
const handleSendScheduledAttachments = useLastCallback(
(sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true) => {
requestCalendar((scheduledAt, scheduleRepeatPeriod) => {
(
sendCompressed: boolean, sendGrouped: boolean, isInvertedMedia?: true,
scheduledAt?: number, scheduleRepeatPeriod?: number,
) => {
if (scheduledAt) {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
@ -2022,7 +2076,18 @@ const Composer = ({
currentMessageList!,
undefined,
);
});
} else {
requestCalendar((calendarScheduledAt, calendarRepeatPeriod) => {
handleActionWithPaymentConfirmation(
handleMessageSchedule,
{ sendCompressed, sendGrouped, isInvertedMedia },
calendarScheduledAt,
calendarRepeatPeriod,
currentMessageList!,
undefined,
);
});
}
},
);
@ -2191,6 +2256,17 @@ const Composer = ({
</g>
</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 && (
<>
<InlineBotTooltip
@ -2739,6 +2815,7 @@ export default memo(withGlobal<OwnProps>(
forwardedMessagesCount: isForwarding ? forwardMessageIds!.length : undefined,
pollModal: tabState.pollModal,
todoListModal: tabState.todoListModal,
aiMessageEditorPendingResult: tabState.aiMessageEditorPendingResult,
stickersForEmoji: global.stickers.forEmoji.stickers,
customEmojiForEmoji: global.customEmojis.forEmoji.stickers,
chatFullInfo,
@ -2794,7 +2871,8 @@ export default memo(withGlobal<OwnProps>(
paidMessagesStars,
shouldPaidMessageAutoApprove,
isSilentPosting,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen,
isPaymentMessageConfirmDialogOpen: tabState.isPaymentMessageConfirmDialogOpen
&& !tabState.aiMessageEditorModal,
starsBalance,
isStarsBalanceModalOpen,
shouldDisplayGiftsButton: userFullInfo?.shouldDisplayGiftsButton,

View File

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

View File

@ -685,6 +685,25 @@ function processEntity({
{renderNestedMessagePart()}
</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:
return renderNestedMessagePart();
}

View File

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

View File

@ -49,6 +49,7 @@ import PremiumSubscriptionOption from './PremiumSubscriptionOption';
import styles from './PremiumMainModal.module.scss';
import PremiumAds from '../../../assets/premium/PremiumAds.svg';
import PremiumAi from '../../../assets/premium/PremiumAi.svg';
import PremiumBadge from '../../../assets/premium/PremiumBadge.svg';
import PremiumChats from '../../../assets/premium/PremiumChats.svg';
import PremiumEffects from '../../../assets/premium/PremiumEffects.svg';
@ -89,6 +90,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record<ApiPremiumSection, string> = {
last_seen: PremiumLastSeen,
message_privacy: PremiumMessagePrivacy,
effects: PremiumEffects,
ai_compose: PremiumAi,
todo: PremiumBadge,
pm_noforwards: PremiumNoforwards,
};
@ -444,7 +446,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
</div>
<div className={buildClassName(styles.list, isPremium && styles.noButton)}>
{filteredSections.map((section, index) => {
const shouldUseNewLang = section === 'todo' || section === 'pm_noforwards';
const shouldUseNewLang = section === 'todo' || section === 'pm_noforwards' || section === 'ai_compose';
return (
<PremiumFeatureItem
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 {
align-self: center;
align-self: flex-end;
margin-right: -1.5rem;
margin-left: -0.25rem !important;
color: var(--color-composer-button);
@ -146,6 +146,27 @@
align-items: center;
}
.aiButton {
position: absolute;
z-index: 3;
top: 0.5rem;
right: 0.5rem;
width: 1.75rem;
height: 1.75rem;
color: var(--color-composer-button);
opacity: 1;
transition: opacity 150ms ease-out;
}
.aiButtonHidden {
pointer-events: none;
opacity: 0;
}
.dropTarget {
position: relative;
overflow: hidden;
@ -219,6 +240,8 @@
.send-wrapper {
position: relative;
align-self: flex-end;
margin-bottom: 0.1875rem;
}
.send {

View File

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

View File

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

View File

@ -76,6 +76,13 @@
transform: translateY(calc(-100% - 0.5rem));
}
.TranslationToneSelector {
position: absolute;
top: 0;
left: 100%;
transform: translateX(0.5rem);
}
.no-forwards-notice {
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
.text-entity-pre {
position: relative;

View File

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

View File

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

View File

@ -25,7 +25,8 @@ export type OwnProps = {
size?: 'default' | 'smaller' | 'tiny';
color?: (
'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;
id?: string;

View File

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

View File

@ -19,6 +19,7 @@ type OwnProps = {
positionY?: 'top' | 'bottom';
footer?: string;
forceOpen?: boolean;
withPortal?: boolean;
onOpen?: NoneToVoidFunction;
onClose?: NoneToVoidFunction;
onHide?: NoneToVoidFunction;
@ -39,6 +40,7 @@ const DropdownMenu: FC<OwnProps> = ({
positionY = 'top',
footer,
forceOpen,
withPortal,
onOpen,
onClose,
onTransitionEnd,
@ -114,6 +116,7 @@ const DropdownMenu: FC<OwnProps> = ({
positionY={positionY}
footer={footer}
autoClose={autoClose}
withPortal={withPortal}
onClose={handleClose}
onCloseAnimationEnd={onHide}
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 type { ApiMessageEntityCustomEmoji } from '../../api/types';
import type { IconName } from '../../types/icons';
import type { MenuItemContextAction } from './ListItem';
import animateHorizontalScroll from '../../util/animateHorizontalScroll';
@ -19,6 +20,7 @@ import './SquareTabList.scss';
export type TabWithProperties = {
id?: number;
title: TeactNode;
icon?: IconName;
badgeCount?: number;
isBlocked?: boolean;
isBadgeActive?: boolean;

View File

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

View File

@ -10,14 +10,22 @@ import useHorizontalScroll from '../../hooks/useHorizontalScroll';
import useLastCallback from '../../hooks/useLastCallback';
import useResizeObserver from '../../hooks/useResizeObserver';
import CustomEmoji from '../common/CustomEmoji';
import Icon from '../common/icons/Icon';
import styles from './TabList.module.scss';
const EMOJI_SIZE = 20;
type OwnProps = {
tabs: readonly TabWithProperties[];
activeTab: number;
className?: string;
tabClassName?: string;
indicatorClassName?: string;
centered?: boolean;
stretched?: boolean;
itemAlignment?: 'vertical' | 'horizontal';
onSwitchTab: (index: number) => void;
};
@ -25,6 +33,11 @@ const TabList = ({
tabs,
activeTab,
className,
tabClassName,
indicatorClassName,
centered,
stretched,
itemAlignment,
onSwitchTab,
}: OwnProps) => {
const containerRef = useRef<HTMLDivElement>();
@ -43,7 +56,9 @@ const TabList = ({
const left = (offsetLeft / 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;
const renderTab = (tab: TabWithProperties, index: number) => (
<div
key={tab.id ?? index}
className={styles.tab}
onClick={() => handleTabClick(index)}
>
{tab.title}
{tab.isBlocked && <Icon name="lock-badge" className={styles.lockIcon} />}
</div>
);
const renderTab = (tab: TabWithProperties, index: number) => {
const stringEmoticon = typeof tab.emoticon === 'string' ? tab.emoticon : undefined;
const customEmoji = typeof tab.emoticon === 'object' ? tab.emoticon : undefined;
return (
<div
key={tab.id ?? index}
className={buildClassName(
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 (
<div
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)}
<div
ref={clipPathContainerRef}
className={styles.activeIndicator}
className={buildClassName(styles.activeIndicator,
centered && styles.centered,
stretched && styles.stretched,
indicatorClassName)}
style={clipPath ? `clip-path: ${clipPath}` : undefined}
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 {
animation-play-state: paused;
}

View File

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

View File

@ -7,6 +7,7 @@ import './api/middleSearch';
import './api/management';
import './api/sync';
import './api/accounts';
import './api/ai';
import './api/users';
import './api/bots';
import './api/settings';
@ -24,6 +25,7 @@ import './ui/globalSearch';
import './ui/middleSearch';
import './ui/stickerSearch';
import './ui/account';
import './ui/aiMessageEditor';
import './ui/users';
import './ui/settings';
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;
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: ({
chatId: string;
gifts?: number[] | undefined;

View File

@ -10,6 +10,7 @@ import type {
ApiChatType,
ApiCheckedGiftCode,
ApiCollectibleInfo,
ApiComposedMessageWithAI,
ApiDialog,
ApiEmojiStatusCollectible,
ApiFormattedText,
@ -107,6 +108,12 @@ export type PollVote = {
date: number;
};
export type AiEditorTabBase = {
isLoading?: boolean;
result?: ApiComposedMessageWithAI;
error?: 'floodPremium' | 'aiError' | 'generic';
};
export type TabState = {
id: number;
isBlurred?: boolean;
@ -662,6 +669,36 @@ export type TabState = {
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?: {
chatId: string;
isOpen?: boolean;

View File

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

View File

@ -1811,6 +1811,7 @@ messages.getFutureChatCreatorAfterLeave#3b7d0ea6 peer:InputPeer = User;
messages.editChatParticipantRank#a00f32b0 peer:InputPeer participant:InputPeer rank:string = Updates;
messages.declineUrlAuth#35436bbc url: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.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;

View File

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

View File

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

View File

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

View File

@ -1772,6 +1772,8 @@ export interface LangPair {
'ToDoListErrorChooseTitle': undefined;
'ToDoListErrorChooseTasks': undefined;
'PremiumPreviewTodo': undefined;
'PremiumPreviewAiTools': undefined;
'PremiumPreviewAiToolsDescription': undefined;
'NativeDownloadFailed': undefined;
'DescriptionAboutTon': undefined;
'ButtonTopUpViaFragment': undefined;
@ -2065,6 +2067,31 @@ export interface LangPair {
'ReminderSetToast': undefined;
'NoForwardsRequestReject': 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> {
@ -3625,6 +3652,9 @@ export interface LangPairWithVariables<V = LangVariable> {
'RankEditText': {
'user': V;
};
'AiMessageEditorDailyLimitReached': {
'link': V;
};
'UnofficialSecurityRisk': {
'peer': V;
};

View File

@ -1,7 +1,22 @@
export default function calcTextLineHeightAndCount(textContainer: HTMLElement) {
const lineHeight = parseInt(getComputedStyle(textContainer).lineHeight, 10);
export default function calcTextLineHeightAndCount(
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 {
totalLines,

View File

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