Composer: Support AI messages (#6826)
This commit is contained in:
parent
d7e3456550
commit
d4138b0ebd
@ -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 {
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
1
src/assets/font-icons/ai-edit.svg
Normal file
1
src/assets/font-icons/ai-edit.svg
Normal 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 |
1
src/assets/font-icons/ai-fix.svg
Normal file
1
src/assets/font-icons/ai-fix.svg
Normal 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 |
1
src/assets/font-icons/ai.svg
Normal file
1
src/assets/font-icons/ai.svg
Normal 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 |
@ -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.";
|
||||
|
||||
1
src/assets/premium/PremiumAi.svg
Normal file
1
src/assets/premium/PremiumAi.svg
Normal 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 |
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -120,6 +120,7 @@ const RecipientPicker = ({
|
||||
orderedFolderIds,
|
||||
chatFoldersById,
|
||||
maxFolders,
|
||||
noEmoticons: true,
|
||||
isReadOnly: true,
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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'] },
|
||||
)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
@ -0,0 +1,3 @@
|
||||
.section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@ -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);
|
||||
@ -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;
|
||||
}
|
||||
@ -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));
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
80
src/components/middle/message/TranslationToneSelector.tsx
Normal file
80
src/components/middle/message/TranslationToneSelector.tsx
Normal 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));
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -55,7 +55,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
54
src/components/ui/ExpandableText.module.scss
Normal file
54
src/components/ui/ExpandableText.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
79
src/components/ui/ExpandableText.tsx
Normal file
79
src/components/ui/ExpandableText.tsx
Normal 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);
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8125rem;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 0.5rem;
|
||||
border-radius: 0.125rem;
|
||||
}
|
||||
45
src/components/ui/placeholder/TextLoadingPlaceholder.tsx
Normal file
45
src/components/ui/placeholder/TextLoadingPlaceholder.tsx
Normal 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);
|
||||
@ -416,6 +416,7 @@ export const PREMIUM_FEATURE_SECTIONS = [
|
||||
'last_seen',
|
||||
'message_privacy',
|
||||
'effects',
|
||||
'ai_compose',
|
||||
'todo',
|
||||
'pm_noforwards',
|
||||
] as const;
|
||||
|
||||
@ -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';
|
||||
|
||||
144
src/global/actions/api/ai.ts
Normal file
144
src/global/actions/api/ai.ts
Normal 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);
|
||||
});
|
||||
189
src/global/actions/ui/aiMessageEditor.ts
Normal file
189
src/global/actions/ui/aiMessageEditor.ts
Normal 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);
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -204,6 +204,7 @@
|
||||
"messages.getAvailableEffects",
|
||||
"messages.setDefaultReaction",
|
||||
"messages.translateText",
|
||||
"messages.composeMessageWithAI",
|
||||
"messages.getAttachMenuBots",
|
||||
"messages.getAttachMenuBot",
|
||||
"messages.toggleBotInAttachMenu",
|
||||
|
||||
@ -164,4 +164,5 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
|
||||
passkeysMaxCount: 5,
|
||||
diceEmojies: [],
|
||||
diceEmojiesSuccess: {},
|
||||
aiComposeStyles: [],
|
||||
};
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
@ -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.
@ -294,6 +294,9 @@ export type FontIconName =
|
||||
| 'animals'
|
||||
| 'allow-speak'
|
||||
| 'allow-share'
|
||||
| 'ai'
|
||||
| 'ai-fix'
|
||||
| 'ai-edit'
|
||||
| 'admin'
|
||||
| 'add'
|
||||
| 'add-user'
|
||||
|
||||
30
src/types/language.d.ts
vendored
30
src/types/language.d.ts
vendored
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -259,6 +259,15 @@ function getEntityDataFromNode(
|
||||
};
|
||||
}
|
||||
|
||||
if (type === ApiMessageEntityTypes.DiffInsert
|
||||
|| type === ApiMessageEntityTypes.DiffReplace
|
||||
|| type === ApiMessageEntityTypes.DiffDelete) {
|
||||
return {
|
||||
index,
|
||||
entity: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
entity: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user