Ai Tone Editor: Support custom AI tone editing (#6965)

This commit is contained in:
Alexander Zinchuk 2026-06-01 01:16:17 +02:00
parent d82240b06c
commit ac713094f8
49 changed files with 1867 additions and 366 deletions

View File

@ -27,7 +27,8 @@ type Limit =
| 'chatlist_joined_limit' | 'chatlist_joined_limit'
| 'recommended_channels_limit' | 'recommended_channels_limit'
| 'saved_dialogs_pinned_limit' | 'saved_dialogs_pinned_limit'
| 'reactions_user_max'; | 'reactions_user_max'
| 'aicompose_tone_saved_limit';
type LimitKey = `${Limit}_${LimitType}`; type LimitKey = `${Limit}_${LimitType}`;
type LimitsConfig = Record<LimitKey, number>; type LimitsConfig = Record<LimitKey, number>;
@ -135,8 +136,6 @@ export interface GramJsAppConfig extends LimitsConfig {
aicompose_tone_examples_num?: number; aicompose_tone_examples_num?: number;
aicompose_tone_title_length_max?: number; aicompose_tone_title_length_max?: number;
aicompose_tone_prompt_length_max?: number; aicompose_tone_prompt_length_max?: number;
aicompose_tone_saved_limit_default?: number;
aicompose_tone_saved_limit_premium?: number;
} }
function buildEmojiSounds(appConfig: GramJsAppConfig) { function buildEmojiSounds(appConfig: GramJsAppConfig) {
@ -217,6 +216,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
savedDialogsPinned: getLimit(appConfig, 'saved_dialogs_pinned_limit', 'savedDialogsPinned'), savedDialogsPinned: getLimit(appConfig, 'saved_dialogs_pinned_limit', 'savedDialogsPinned'),
maxReactions: getLimit(appConfig, 'reactions_user_max', 'maxReactions'), maxReactions: getLimit(appConfig, 'reactions_user_max', 'maxReactions'),
moreAccounts: DEFAULT_LIMITS.moreAccounts, moreAccounts: DEFAULT_LIMITS.moreAccounts,
aiComposeToneSaved: getLimit(appConfig, 'aicompose_tone_saved_limit', 'aiComposeToneSaved'),
}, },
contactNoteLimit: appConfig.contact_note_length_limit, contactNoteLimit: appConfig.contact_note_length_limit,
hash, hash,
@ -284,8 +284,6 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
aiComposeToneExamplesNum: appConfig.aicompose_tone_examples_num, aiComposeToneExamplesNum: appConfig.aicompose_tone_examples_num,
aiComposeToneTitleLengthMax: appConfig.aicompose_tone_title_length_max, aiComposeToneTitleLengthMax: appConfig.aicompose_tone_title_length_max,
aiComposeTonePromptLengthMax: appConfig.aicompose_tone_prompt_length_max, aiComposeTonePromptLengthMax: appConfig.aicompose_tone_prompt_length_max,
aiComposeToneSavedLimitDefault: appConfig.aicompose_tone_saved_limit_default,
aiComposeToneSavedLimitPremium: appConfig.aicompose_tone_saved_limit_premium,
}; };
return { return {

View File

@ -1004,6 +1004,10 @@ export function buildWebPage(webPage: GramJs.TypeWebPage): ApiWebPage | undefine
}; };
} }
const attributeAiTone = attributes?.find((a): a is GramJs.WebPageAttributeAiComposeTone => (
a instanceof GramJs.WebPageAttributeAiComposeTone
));
return { return {
mediaType: 'webpage', mediaType: 'webpage',
webpageType: 'full', webpageType: 'full',
@ -1026,6 +1030,7 @@ export function buildWebPage(webPage: GramJs.TypeWebPage): ApiWebPage | undefine
gift, gift,
auction, auction,
stickers, stickers,
aiComposeToneEmojiId: attributeAiTone?.emojiId.toString(),
}; };
} }

View File

@ -64,7 +64,9 @@ import {
buildApiSponsoredMessageReportResult, buildApiSponsoredMessageReportResult,
buildThreadReadState, buildThreadReadState,
} from '../apiBuilders/chats'; } from '../apiBuilders/chats';
import { buildApiAiComposeTone, buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common'; import {
buildApiAiComposeTone, buildApiAiComposeToneExample, buildApiComposedMessageWithAI, buildApiFormattedText,
} from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums'; import { buildApiTopicWithState } from '../apiBuilders/forums';
import { import {
buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent, buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent,
@ -2853,3 +2855,110 @@ export async function fetchAiComposeTones({
hash: result.hash.toString(), hash: result.hash.toString(),
}; };
} }
export async function createAiTone({
title,
emojiId,
prompt,
shouldDisplayAuthor,
}: {
title: string;
emojiId: string;
prompt: string;
shouldDisplayAuthor?: boolean;
}) {
const result = await invokeRequest(new GramJs.aicompose.CreateTone({
title,
prompt,
emojiId: BigInt(emojiId),
displayAuthor: shouldDisplayAuthor || undefined,
}));
if (!result) return undefined;
return buildApiAiComposeTone(result);
}
export async function deleteAiTone({
tone,
}: {
tone: ApiInputAiComposeTone;
}) {
return invokeRequest(new GramJs.aicompose.DeleteTone({
tone: buildInputAiComposeTone(tone),
}));
}
export async function updateAiTone({
tone,
title,
emojiId,
prompt,
shouldDisplayAuthor,
}: {
tone: ApiInputAiComposeTone;
title?: string;
emojiId?: string;
prompt?: string;
shouldDisplayAuthor?: boolean;
}) {
const result = await invokeRequest(new GramJs.aicompose.UpdateTone({
tone: buildInputAiComposeTone(tone),
title,
prompt,
emojiId: emojiId ? BigInt(emojiId) : undefined,
displayAuthor: shouldDisplayAuthor,
}));
if (!result) return undefined;
return buildApiAiComposeTone(result);
}
export async function fetchAiTone({
tone,
}: {
tone: ApiInputAiComposeTone;
}) {
const result = await invokeRequest(new GramJs.aicompose.GetTone({
tone: buildInputAiComposeTone(tone),
}));
if (!result || !('tones' in result)) {
return undefined;
}
return {
tones: result.tones.map(buildApiAiComposeTone),
};
}
export async function fetchAiToneExample({
tone,
num,
}: {
tone: ApiInputAiComposeTone;
num: number;
}) {
const result = await invokeRequest(new GramJs.aicompose.GetToneExample({
tone: buildInputAiComposeTone(tone),
num,
}));
if (!result) return undefined;
return buildApiAiComposeToneExample(result);
}
export async function saveAiTone({
tone,
unsave,
}: {
tone: ApiInputAiComposeTone;
unsave?: boolean;
}) {
return invokeRequest(new GramJs.aicompose.SaveTone({
tone: buildInputAiComposeTone(tone),
unsave: Boolean(unsave),
}));
}

View File

@ -426,6 +426,7 @@ export interface ApiWebPageFull {
gift?: ApiStarGiftUnique; gift?: ApiStarGiftUnique;
auction?: ApiWebPageAuctionData; auction?: ApiWebPageAuctionData;
stickers?: ApiWebPageStickerData; stickers?: ApiWebPageStickerData;
aiComposeToneEmojiId?: string;
hasLargeMedia?: boolean; hasLargeMedia?: boolean;
} }

View File

@ -337,8 +337,6 @@ export interface ApiAppConfig {
aiComposeToneExamplesNum?: number; aiComposeToneExamplesNum?: number;
aiComposeToneTitleLengthMax?: number; aiComposeToneTitleLengthMax?: number;
aiComposeTonePromptLengthMax?: number; aiComposeTonePromptLengthMax?: number;
aiComposeToneSavedLimitDefault?: number;
aiComposeToneSavedLimitPremium?: number;
} }
export interface ApiConfig { export interface ApiConfig {
@ -464,15 +462,17 @@ export type ApiLimitType =
| 'recommendedChannels' | 'recommendedChannels'
| 'savedDialogsPinned' | 'savedDialogsPinned'
| 'maxReactions' | 'maxReactions'
| 'moreAccounts'; | 'moreAccounts'
| 'aiComposeToneSaved';
export type ApiLimitTypeWithModal = Exclude<ApiLimitType, ( export type ApiLimitTypeWithModal = Exclude<ApiLimitType, (
'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs' | 'recommendedChannels' | 'moreAccounts' 'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs' | 'recommendedChannels' | 'moreAccounts'
| 'maxReactions' | 'maxReactions' | 'aiComposeToneSaved'
)>; )>;
export type ApiLimitTypeForPromo = Exclude<ApiLimitType, export type ApiLimitTypeForPromo = Exclude<ApiLimitType,
'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined' | 'savedDialogsPinned' | 'maxReactions' 'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined' | 'savedDialogsPinned' | 'maxReactions'
| 'aiComposeToneSaved'
>; >;
export type ApiPeerNotifySettings = { export type ApiPeerNotifySettings = {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M22.609 22.136a.226.226 0 0 0-.276.04A8.82 8.82 0 0 1 16 24.859c-4.885 0-8.858-3.975-8.858-8.86 0-.237.034-.466.052-.698a16 16 0 0 0-.73-.13c-.258-.03-.516-.06-.774-.081q-.26 0-.501-.066a11 11 0 0 0-.047.976c0 5.988 4.87 10.859 10.858 10.859 3.183 0 6.045-1.382 8.032-3.572.1-.11.07-.289-.059-.363zM24.77 17.184c.19-.075.388-.12.6-.113q.124 0 .246.012.308.033.614.083a1.6 1.6 0 0 1 .329.093c.068.028.135.06.203.087.057-.442.097-.89.097-1.346C26.859 10.012 21.987 5.14 16 5.14a10.82 10.82 0 0 0-8.032 3.572c-.1.11-.07.289.06.363l1.363.788c.09.052.203.035.276-.04A8.82 8.82 0 0 1 16 7.141c4.885 0 8.859 3.974 8.859 8.859 0 .402-.037.796-.09 1.184"/><path d="M6.241 12.481 1.8 17.451a.185.185 0 0 0 .138.308h8.606c.16 0 .245-.189.138-.308zM25.759 19.519l4.441-4.97a.185.185 0 0 0-.138-.308h-8.606c-.16 0-.245.189-.138.308z"/></svg>

After

Width:  |  Height:  |  Size: 897 B

View File

@ -1910,6 +1910,7 @@
"ViewButtonStickerset" = "VIEW STICKERS"; "ViewButtonStickerset" = "VIEW STICKERS";
"ViewButtonEmojiset" = "VIEW EMOJI"; "ViewButtonEmojiset" = "VIEW EMOJI";
"ViewButtonGiftUnique" = "VIEW COLLECTIBLE"; "ViewButtonGiftUnique" = "VIEW COLLECTIBLE";
"ViewButtonAiStyle" = "VIEW STYLE";
"AuthContinueOnThisLanguage" = "Continue in English"; "AuthContinueOnThisLanguage" = "Continue in English";
"Share" = "Share"; "Share" = "Share";
"GiftSortByDate" = "Sort by Date"; "GiftSortByDate" = "Sort by Date";
@ -2889,6 +2890,32 @@
"AiMessageEditorApply" = "Apply"; "AiMessageEditorApply" = "Apply";
"AiMessageEditorEmojify" = "emojify"; "AiMessageEditorEmojify" = "emojify";
"AiMessageEditorTranslation" = "Translation"; "AiMessageEditorTranslation" = "Translation";
"AiToneEditorNewStyle" = "New Style";
"AiToneEditorTitle" = "Create AI Tone";
"AiToneEditorNamePlaceholder" = "Style Name (for example: Pirate)";
"AiToneEditorPromptPlaceholder" = "Instructions (for example write in bold, nautical tone, light slang, vivid sea imagery, playful swagger)";
"AiToneEditorDisplayAuthor" = "Add a link to my account";
"AiToneEditorSelectEmoji" = "Select Emoji";
"AiToneCreated" = "{title} style created!";
"AiToneCreatedHint" = "Press and hold a style to edit or share it.";
"AiToneEditStyle" = "Edit Style";
"AiToneShareStyle" = "Share Style";
"AiToneDeleteStyle" = "Delete Style";
"AiToneDeleteStyleConfirmOwn" = "Are you sure you want to delete this style? It will be removed for everyone who installed it.";
"AiToneDeleteStyleConfirm" = "Are you sure you want to remove this style?";
"AiToneEditorEditTitle" = "Edit AI Style";
"AiTonePreviewSubtitle" = "Add this style to instantly rewrite your messages.";
"AiTonePreviewBefore" = "Before";
"AiTonePreviewAnotherExample" = "Another Example";
"AiTonePreviewAfter" = "After";
"AiTonePreviewAddStyle" = "Add Style";
"AiTonePreviewRemoveStyle" = "Remove Style";
"AiTonePreviewStyleAdded" = "Style added";
"AiToneLimitReached" = "You have reached the limit of custom styles.";
"AiToneLimitReachedPremium" = "You have reached the limit of **{limit}** custom styles.";
"AiTonePreviewUsedBy" = "Used by {count} people.";
"AiTonePreviewCreatedBy" = "Created by {author}.";
"AiTonePreviewUsedByCreatedBy" = "{usedBy} {createdBy}";
"TextShowMore" = "more"; "TextShowMore" = "more";
"TextShowLess" = "less"; "TextShowLess" = "less";
"AiMessageEditorFrom" = "From"; "AiMessageEditorFrom" = "From";

View File

@ -73,6 +73,10 @@ export { default as ReactionPicker } from '../components/middle/message/reaction
export { default as AiMessageEditorModal } export { default as AiMessageEditorModal }
from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal'; from '../components/middle/composer/AiMessageEditorModal/AiMessageEditorModal';
export { default as AiToneEmojiPickerModal }
from '../components/middle/composer/AiMessageEditorModal/AiToneEmojiPickerModal';
export { default as AiTonePreviewModal }
from '../components/modals/aiTonePreview/AiTonePreviewModal';
export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal'; export { default as AttachmentModal } from '../components/middle/composer/AttachmentModal';
export { default as PollModal } from '../components/modals/poll/PollModal'; export { default as PollModal } from '../components/modals/poll/PollModal';

View File

@ -68,6 +68,7 @@ type OwnProps = {
isStatusPicker?: boolean; isStatusPicker?: boolean;
isReactionPicker?: boolean; isReactionPicker?: boolean;
isTranslucent?: boolean; isTranslucent?: boolean;
noAddButton?: boolean;
onCustomEmojiSelect: (sticker: ApiSticker) => void; onCustomEmojiSelect: (sticker: ApiSticker) => void;
onReactionSelect?: (reaction: ApiReactionWithPaid) => void; onReactionSelect?: (reaction: ApiReactionWithPaid) => void;
onReactionContext?: (reaction: ApiReactionWithPaid) => void; onReactionContext?: (reaction: ApiReactionWithPaid) => void;
@ -130,6 +131,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
isReactionPicker, isReactionPicker,
isStatusPicker, isStatusPicker,
isTranslucent, isTranslucent,
noAddButton,
isSavedMessages, isSavedMessages,
isCurrentUserPremium, isCurrentUserPremium,
withDefaultTopicIcons, withDefaultTopicIcons,
@ -451,6 +453,7 @@ const CustomEmojiPicker: FC<OwnProps & StateProps> = ({
isSavedMessages={isSavedMessages} isSavedMessages={isSavedMessages}
isStatusPicker={isStatusPicker} isStatusPicker={isStatusPicker}
isReactionPicker={isReactionPicker} isReactionPicker={isReactionPicker}
noAddButton={noAddButton}
shouldHideHeader={shouldHideHeader} shouldHideHeader={shouldHideHeader}
withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID} withDefaultTopicIcon={withDefaultTopicIcons && stickerSet.id === RECENT_SYMBOL_SET_ID}
withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID} withDefaultStatusIcon={isStatusPicker && stickerSet.id === RECENT_SYMBOL_SET_ID}

View File

@ -62,6 +62,7 @@ type OwnProps = {
isChatStickerSet?: boolean; isChatStickerSet?: boolean;
isTranslucent?: boolean; isTranslucent?: boolean;
noContextMenus?: boolean; noContextMenus?: boolean;
noAddButton?: boolean;
forcePlayback?: boolean; forcePlayback?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
observeIntersectionForPlayingItems: ObserveFn; observeIntersectionForPlayingItems: ObserveFn;
@ -106,6 +107,7 @@ const StickerSet = ({
isChatStickerSet, isChatStickerSet,
isTranslucent, isTranslucent,
noContextMenus, noContextMenus,
noAddButton,
forcePlayback, forcePlayback,
collectibleStatuses, collectibleStatuses,
observeIntersection, observeIntersection,
@ -260,7 +262,7 @@ const StickerSet = ({
const collectibleEmojiIdsSet = useMemo(() => ( const collectibleEmojiIdsSet = useMemo(() => (
collectibleStatuses ? new Set(collectibleStatuses.map(({ documentId }) => documentId)) : undefined collectibleStatuses ? new Set(collectibleStatuses.map(({ documentId }) => documentId)) : undefined
), [collectibleStatuses]); ), [collectibleStatuses]);
const withAddSetButton = !shouldHideHeader && !isRecent && !isStatusCollectible const withAddSetButton = !noAddButton && !shouldHideHeader && !isRecent && !isStatusCollectible
&& isEmoji && !isPopular && !isChatEmojiSet && isEmoji && !isPopular && !isChatEmojiSet
&& (!isInstalled || (!isCurrentUserPremium && !isSavedMessages)); && (!isInstalled || (!isCurrentUserPremium && !isSavedMessages));
const addSetButtonText = useMemo(() => { const addSetButtonText = useMemo(() => {

View File

@ -143,6 +143,11 @@
background-color: transparent; background-color: transparent;
} }
.stickyFooter {
position: relative;
flex-shrink: 0;
}
.withHeader { .withHeader {
mask-image: mask-image:
linear-gradient( linear-gradient(

View File

@ -43,6 +43,10 @@ export type ModalProps = {
height?: ModalHeight; height?: ModalHeight;
noBackdrop?: boolean; noBackdrop?: boolean;
noLightDismiss?: boolean; noLightDismiss?: boolean;
noScrollable?: boolean;
noContentInlinePadding?: boolean;
keepMounted?: boolean;
stickyFooter?: TeactNode;
ariaLabel?: string; ariaLabel?: string;
noContainment?: boolean; noContainment?: boolean;
onClose: NoneToVoidFunction; onClose: NoneToVoidFunction;
@ -114,10 +118,15 @@ const Modal = ({
height = 'regular', height = 'regular',
noBackdrop, noBackdrop,
noLightDismiss, noLightDismiss,
noScrollable,
noContentInlinePadding,
keepMounted,
stickyFooter,
ariaLabel, ariaLabel,
noContainment, noContainment,
onClose, onClose,
}: ModalProps) => { }: ModalProps) => {
const [hasEverOpened, setHasEverOpened] = useState(Boolean(isOpen));
const [shouldRender, setShouldRender] = useState(Boolean(isOpen)); const [shouldRender, setShouldRender] = useState(Boolean(isOpen));
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [hasTitle, setHasTitle] = useState(false); const [hasTitle, setHasTitle] = useState(false);
@ -134,11 +143,14 @@ const Modal = ({
const frozenProps = useFrozenProps({ const frozenProps = useFrozenProps({
header, header,
children, children,
stickyFooter,
dialogClassName, dialogClassName,
contentClassName, contentClassName,
width, width,
height, height,
noBackdrop, noBackdrop,
noScrollable,
noContentInlinePadding,
ariaLabel, ariaLabel,
noContainment, noContainment,
}, !isOpen); }, !isOpen);
@ -193,6 +205,12 @@ const Modal = ({
titleId, titleId,
]); ]);
useEffect(() => {
if (isOpen && !hasEverOpened) {
setHasEverOpened(true);
}
}, [hasEverOpened, isOpen]);
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
cleanupCloseAnimation(); cleanupCloseAnimation();
@ -322,7 +340,7 @@ const Modal = ({
handleRequestClose(); handleRequestClose();
}); });
if (!shouldRender) { if (!shouldRender && !(keepMounted && hasEverOpened)) {
return undefined; return undefined;
} }
@ -355,7 +373,8 @@ const Modal = ({
)} )}
<Surface <Surface
scrollable scrollable={!frozenProps.noScrollable}
noPadding={frozenProps.noContentInlinePadding}
className={buildClassName( className={buildClassName(
styles.content, styles.content,
shouldShowHeader && styles.withHeader, shouldShowHeader && styles.withHeader,
@ -366,6 +385,11 @@ const Modal = ({
{frozenProps.children} {frozenProps.children}
</div> </div>
</Surface> </Surface>
{Boolean(frozenProps.stickyFooter) && (
<div className={styles.stickyFooter}>
{frozenProps.stickyFooter}
</div>
)}
</div> </div>
</dialog> </dialog>
</ModalContext.Provider> </ModalContext.Provider>

View File

@ -0,0 +1,9 @@
@layer ui.templates {
.centered {
justify-content: center;
}
.centeredControl {
flex-grow: 0;
}
}

View File

@ -1,5 +1,7 @@
import { memo } from '../../../lib/teact/teact'; import { memo } from '../../../lib/teact/teact';
import buildClassName from '../../../util/buildClassName';
import Control, { import Control, {
ControlDescription, ControlDescription,
ControlLabel, ControlLabel,
@ -7,11 +9,14 @@ import Control, {
import Interactive from '../layout/Interactive'; import Interactive from '../layout/Interactive';
import Checkbox from '../primitives/Checkbox'; import Checkbox from '../primitives/Checkbox';
import styles from './CheckboxField.module.scss';
type Props = Omit<React.ComponentProps<typeof Checkbox>, 'className' | 'disabled'> & { type Props = Omit<React.ComponentProps<typeof Checkbox>, 'className' | 'disabled'> & {
label: string; label: string;
description?: string; description?: string;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
isCentered?: boolean;
className?: string; className?: string;
controlClassName?: string; controlClassName?: string;
labelClassName?: string; labelClassName?: string;
@ -24,6 +29,7 @@ const CheckboxField = ({
description, description,
disabled, disabled,
loading, loading,
isCentered,
className, className,
controlClassName, controlClassName,
labelClassName, labelClassName,
@ -38,9 +44,9 @@ const CheckboxField = ({
ripple={ripple} ripple={ripple}
disabled={disabled} disabled={disabled}
loading={loading} loading={loading}
className={className} className={buildClassName(className, isCentered && styles.centered)}
> >
<Control className={controlClassName}> <Control className={buildClassName(controlClassName, isCentered && styles.centeredControl)}>
<Checkbox {...checkboxProps} /> <Checkbox {...checkboxProps} />
<ControlLabel className={labelClassName}>{label}</ControlLabel> <ControlLabel className={labelClassName}>{label}</ControlLabel>
{description !== undefined ? ( {description !== undefined ? (

View File

@ -64,6 +64,7 @@ const Dialogs = ({ dialogs, currentMessageList }: StateProps) => {
onClose={closeModal} onClose={closeModal}
className="confirm" className="confirm"
title={lang('ShareYouPhoneNumberTitle')} title={lang('ShareYouPhoneNumberTitle')}
isNativeDialog
onCloseAnimationEnd={dismissDialog} onCloseAnimationEnd={dismissDialog}
> >
{lang( {lang(
@ -94,6 +95,7 @@ const Dialogs = ({ dialogs, currentMessageList }: StateProps) => {
onCloseAnimationEnd={dismissDialog} onCloseAnimationEnd={dismissDialog}
className="error" className="error"
title={title} title={title}
isNativeDialog
> >
{renderedText} {renderedText}
<div className="dialog-buttons mt-2"> <div className="dialog-buttons mt-2">

View File

@ -32,7 +32,9 @@
.resultArea { .resultArea {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
}
.resultAreaAnimated {
/* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */
transition: height 0.1s; transition: height 0.1s;
} }

View File

@ -23,6 +23,7 @@ type AiEditorResultAreaProps = {
isLoading?: boolean; isLoading?: boolean;
transitionKey?: number; transitionKey?: number;
className?: string; className?: string;
loadingElement?: TeactNode;
children: TeactNode; children: TeactNode;
}; };
@ -30,6 +31,7 @@ export const AiEditorResultArea = memo(({
isLoading, isLoading,
transitionKey, transitionKey,
className, className,
loadingElement,
children, children,
}: AiEditorResultAreaProps) => { }: AiEditorResultAreaProps) => {
const contentRef = useRef<HTMLDivElement>(); const contentRef = useRef<HTMLDivElement>();
@ -45,15 +47,16 @@ export const AiEditorResultArea = memo(({
}); });
}, [children, isLoading, transitionKey]); }, [children, isLoading, transitionKey]);
const displayHeight = height ?? MIN_HEIGHT; const hasInitialized = height !== undefined;
const displayHeight = hasInitialized ? height : (isLoading ? MIN_HEIGHT : undefined);
return ( return (
<div <div
className={buildClassName(styles.resultArea, className)} className={buildClassName(styles.resultArea, hasInitialized && styles.resultAreaAnimated, className)}
style={`height: ${displayHeight}px`} style={displayHeight !== undefined ? `height: ${displayHeight}px` : undefined}
> >
<div className={buildClassName(styles.loadingContainer, !isLoading && styles.hidden)}> <div className={buildClassName(styles.loadingContainer, !isLoading && styles.hidden)}>
<TextLoadingPlaceholder lines={6} /> {loadingElement || <TextLoadingPlaceholder lines={6} />}
</div> </div>
<Transition <Transition
name="fade" name="fade"

View File

@ -77,6 +77,7 @@
padding: 0 1rem 1rem; padding: 0 1rem 1rem;
&::before { &::before {
pointer-events: none;
content: ""; content: "";
position: absolute; position: absolute;

View File

@ -24,6 +24,38 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.tabListWrapper {
position: relative;
min-height: 4.125rem;
}
.tabListSkeleton {
position: absolute;
z-index: 1;
inset: 0;
display: flex;
gap: 1.25rem;
align-items: center;
padding: 0.625rem 0;
opacity: 1;
background: var(--color-background);
transition: opacity 0.2s ease-out;
}
.tabListSkeletonHidden {
pointer-events: none;
opacity: 0;
}
.tabSkeleton {
width: 2.25rem;
height: 2.25rem;
}
.textLabel, .textLabel,
.resultLabel { .resultLabel {
font-size: 0.875rem; font-size: 0.875rem;

View File

@ -1,11 +1,14 @@
import { memo, useMemo } from '../../../../lib/teact/teact'; import { memo, useMemo, useState } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global'; import { getActions, withGlobal } from '../../../../global';
import type { import type {
ApiAiComposeToneType, ApiComposedMessageWithAI, ApiFormattedText, ApiInputAiComposeTone, ApiAiComposeToneType, ApiComposedMessageWithAI, ApiFormattedText, ApiInputAiComposeTone,
} from '../../../../api/types'; } from '../../../../api/types';
import type { MenuItemContextAction } from '../../../ui/ListItem';
import type { TabWithProperties } from '../../../ui/TabList'; import type { TabWithProperties } from '../../../ui/TabList';
import { TME_LINK_PREFIX } from '../../../../config';
import { selectTabState } from '../../../../global/selectors';
import { compareAiTones, getInputTone } from '../../../../util/aiComposeTones'; import { compareAiTones, getInputTone } from '../../../../util/aiComposeTones';
import buildClassName from '../../../../util/buildClassName'; import buildClassName from '../../../../util/buildClassName';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
@ -15,9 +18,12 @@ import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback'; import useLastCallback from '../../../../hooks/useLastCallback';
import CheckboxField from '../../../gili/templates/CheckboxField'; import CheckboxField from '../../../gili/templates/CheckboxField';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import Skeleton from '../../../ui/placeholder/Skeleton';
import TabList from '../../../ui/TabList'; import TabList from '../../../ui/TabList';
import Transition from '../../../ui/Transition'; import Transition from '../../../ui/Transition';
import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared'; import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared';
import AiToneEditorModal from './AiToneEditorModal';
import sharedStyles from './AiEditorShared.module.scss'; import sharedStyles from './AiEditorShared.module.scss';
import modalStyles from './AiMessageEditorModal.module.scss'; import modalStyles from './AiMessageEditorModal.module.scss';
@ -35,6 +41,7 @@ type OwnProps = {
type StateProps = { type StateProps = {
tones: ApiAiComposeToneType[]; tones: ApiAiComposeToneType[];
isAiToneEditorOpen?: boolean;
}; };
const AiTextStyleEditor = ({ const AiTextStyleEditor = ({
@ -46,28 +53,97 @@ const AiTextStyleEditor = ({
error, error,
isPremium, isPremium,
tones, tones,
isAiToneEditorOpen,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
setAiMessageEditorStyleOptions, setAiMessageEditorStyleOptions,
composeWithAiMessageEditor, composeWithAiMessageEditor,
openAiToneEditorModal,
closeAiMessageEditorModal,
deleteAiTone,
openChatWithDraft,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
const [toneToDelete, setToneToDelete] = useState<ApiInputAiComposeTone>();
const [isCreatorDelete, setIsCreatorDelete] = useState(false);
const handleConfirmDelete = useLastCallback(() => {
if (!toneToDelete) return;
deleteAiTone({ tone: toneToDelete });
setToneToDelete(undefined);
});
const handleCloseDeleteConfirm = useLastCallback(() => {
setToneToDelete(undefined);
});
const hasResult = Boolean(result?.resultText); const hasResult = Boolean(result?.resultText);
const hasRequest = Boolean(selectedTone) || shouldEmojify; const hasRequest = Boolean(selectedTone) || shouldEmojify;
const shouldShowError = Boolean(error) && hasRequest; const shouldShowError = Boolean(error) && hasRequest;
const styleTabs = useMemo((): TabWithProperties[] => tones.map((entry) => ({ const buildContextActions = useLastCallback((entry: ApiAiComposeToneType): MenuItemContextAction[] | undefined => {
customEmojiDocumentId: entry.emojiId, if (!('id' in entry)) return undefined;
title: entry.title,
})), [tones]); const tone = getInputTone(entry);
const actions: MenuItemContextAction[] = [];
if (entry.isCreator) {
actions.push({
title: lang('AiToneEditStyle'),
icon: 'edit',
handler: () => {
openAiToneEditorModal({ toneToEdit: entry });
},
});
}
actions.push({
title: lang('AiToneShareStyle'),
icon: 'forward',
handler: () => {
closeAiMessageEditorModal();
openChatWithDraft({ text: { text: `${TME_LINK_PREFIX}addstyle/${entry.slug}` } });
},
});
actions.push({
title: lang('AiToneDeleteStyle'),
icon: 'delete',
destructive: true,
handler: () => {
setToneToDelete(tone);
setIsCreatorDelete(Boolean(entry.isCreator));
},
});
return actions;
});
const styleTabs = useMemo((): TabWithProperties[] => {
const tabs: TabWithProperties[] = tones.map((entry) => ({
customEmojiDocumentId: entry.emojiId,
title: entry.title,
contextActions: buildContextActions(entry),
}));
if (tones.length) {
tabs.push({ icon: 'add', title: lang('AiToneEditorNewStyle') });
}
return tabs;
}, [tones, lang, buildContextActions]);
const activeStyleIndex = tones.findIndex( const activeStyleIndex = tones.findIndex(
(entry) => compareAiTones(selectedTone, getInputTone(entry)), (entry) => compareAiTones(selectedTone, getInputTone(entry)),
); );
const handleStyleSelect = useLastCallback((index: number) => { const handleStyleSelect = useLastCallback((index: number) => {
if (index === tones.length) {
openAiToneEditorModal();
return;
}
const tone = getInputTone(tones[index]); const tone = getInputTone(tones[index]);
setAiMessageEditorStyleOptions({ selectedTone: tone }); setAiMessageEditorStyleOptions({ selectedTone: tone });
composeWithAiMessageEditor({ tone, isEmojify: shouldEmojify }); composeWithAiMessageEditor({ tone, isEmojify: shouldEmojify });
@ -105,15 +181,26 @@ const AiTextStyleEditor = ({
return ( return (
<div className={buildClassName(modalStyles.editorBlock, styles.styleBlock)}> <div className={buildClassName(modalStyles.editorBlock, styles.styleBlock)}>
<TabList <div className={styles.tabListWrapper}>
tabs={styleTabs} {styleTabs.length > 0 && (
activeTab={activeStyleIndex} <TabList
onSwitchTab={handleStyleSelect} tabs={styleTabs}
className={styles.tabList} activeTab={activeStyleIndex}
tabClassName={styles.tab} onSwitchTab={handleStyleSelect}
indicatorClassName={styles.tabListIndicator} className={styles.tabList}
itemAlignment="vertical" tabClassName={styles.tab}
/> indicatorClassName={styles.tabListIndicator}
itemAlignment="vertical"
/>
)}
<div className={buildClassName(styles.tabListSkeleton, styleTabs.length && styles.tabListSkeletonHidden)}>
<Skeleton className={styles.tabSkeleton} variant="round" animation="wave" />
<Skeleton className={styles.tabSkeleton} variant="round" animation="wave" />
<Skeleton className={styles.tabSkeleton} variant="round" animation="wave" />
<Skeleton className={styles.tabSkeleton} variant="round" animation="wave" />
<Skeleton className={styles.tabSkeleton} variant="round" animation="wave" />
</div>
</div>
<div className={sharedStyles.separator} /> <div className={sharedStyles.separator} />
@ -144,6 +231,16 @@ const AiTextStyleEditor = ({
textToCopy={displayText?.text} textToCopy={displayText?.text}
isHidden={isLoading || shouldShowError || !displayText?.text} isHidden={isLoading || shouldShowError || !displayText?.text}
/> />
<AiToneEditorModal isOpen={Boolean(isAiToneEditorOpen)} />
<ConfirmDialog
isOpen={Boolean(toneToDelete)}
title={lang('AiToneDeleteStyle')}
text={lang(isCreatorDelete ? 'AiToneDeleteStyleConfirmOwn' : 'AiToneDeleteStyleConfirm')}
confirmLabel={lang('Delete')}
confirmIsDestructive
onClose={handleCloseDeleteConfirm}
confirmHandler={handleConfirmDelete}
/>
</div> </div>
); );
}; };
@ -152,6 +249,7 @@ export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => { (global): Complete<StateProps> => {
return { return {
tones: global.aiComposeTones?.tones ?? MEMO_EMPTY_ARRAY, tones: global.aiComposeTones?.tones ?? MEMO_EMPTY_ARRAY,
isAiToneEditorOpen: Boolean(selectTabState(global).aiToneEditorModal),
}; };
}, },
)(AiTextStyleEditor)); )(AiTextStyleEditor));

View File

@ -0,0 +1,63 @@
.emojiRow {
position: relative;
display: flex;
justify-content: center;
padding-bottom: 1rem;
}
.emojiButton {
--custom-emoji-size: 3rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
padding: 0;
border: none;
border-radius: 50%;
background: var(--color-background);
outline: none;
transition: opacity 0.15s;
&:hover {
opacity: 0.75;
}
}
.emojiPlaceholderIcon {
font-size: 2rem;
color: var(--color-text-secondary);
}
.input {
margin: 0.5rem;
}
.promptInput {
margin-top: 1rem;
}
.promptInput :global(.form-control) {
min-height: 5.625rem;
}
.footer {
padding-top: 1rem;
}
.deleteButton {
width: 100%;
margin-bottom: 0.5rem;
border-radius: 2rem;
}
.createButton {
width: 100%;
border-radius: 2rem;
}

View File

@ -0,0 +1,243 @@
import { memo, useEffect, useMemo, useState } from '../../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../../global';
import type { ApiAiComposeTone } from '../../../../api/types';
import { selectTabState } from '../../../../global/selectors';
import { getInputTone } from '../../../../util/aiComposeTones';
import buildClassName from '../../../../util/buildClassName';
import useFlag from '../../../../hooks/useFlag';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import CustomEmoji from '../../../common/CustomEmoji';
import Icon from '../../../common/icons/Icon';
import CheckboxField from '../../../gili/templates/CheckboxField';
import Button from '../../../ui/Button';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import InputText from '../../../ui/InputText';
import TextArea from '../../../ui/TextArea';
import AiToneEmojiPickerModal from './AiToneEmojiPickerModal.async';
import Island from '@gili/layout/Island';
import Modal, { ModalCloseButton, ModalHeader, ModalTitle } from '@gili/modal/Modal';
import styles from './AiToneEditorModal.module.scss';
const EMOJI_SIZE = 48;
const DEFAULT_TITLE_MAX_LENGTH = 12;
const DEFAULT_PROMPT_MAX_LENGTH = 1024;
type OwnProps = {
isOpen: boolean;
};
type StateProps = {
toneToEdit?: ApiAiComposeTone;
titleMaxLength?: number;
promptMaxLength?: number;
};
const AiToneEditorModal = ({
isOpen,
toneToEdit,
titleMaxLength = DEFAULT_TITLE_MAX_LENGTH,
promptMaxLength = DEFAULT_PROMPT_MAX_LENGTH,
}: OwnProps & StateProps) => {
const {
closeAiToneEditorModal,
createAiTone,
updateAiTone,
deleteAiTone,
} = getActions();
const lang = useLang();
const isEditMode = Boolean(toneToEdit);
const [emojiId, setEmojiId] = useState<string | undefined>();
const [title, setTitle] = useState('');
const [prompt, setPrompt] = useState('');
const [shouldDisplayAuthor, setShouldDisplayAuthor] = useState(false);
const [isEmojiPickerOpen, openEmojiPicker, closeEmojiPicker] = useFlag();
const [isDeleteConfirmOpen, openDeleteConfirm, closeDeleteConfirm] = useFlag();
useEffect(() => {
if (!isOpen) return;
if (toneToEdit) {
setEmojiId(toneToEdit.emojiId);
setTitle(toneToEdit.title);
setPrompt(toneToEdit.prompt || '');
setShouldDisplayAuthor(Boolean(toneToEdit.authorId));
} else {
setEmojiId(undefined);
setTitle('');
setPrompt('');
setShouldDisplayAuthor(false);
}
}, [isOpen, toneToEdit]);
const canSubmit = Boolean(emojiId && title.trim() && prompt.trim());
const handleClose = useLastCallback(() => {
closeAiToneEditorModal();
closeEmojiPicker();
closeDeleteConfirm();
});
const handleSubmit = useLastCallback(() => {
if (!canSubmit) return;
if (isEditMode) {
const tone = getInputTone(toneToEdit);
updateAiTone({
tone,
title: title.trim(),
emojiId: emojiId!,
prompt: prompt.trim(),
shouldDisplayAuthor: shouldDisplayAuthor || undefined,
});
} else {
createAiTone({
title: title.trim(),
emojiId: emojiId!,
prompt: prompt.trim(),
shouldDisplayAuthor: shouldDisplayAuthor || undefined,
});
}
handleClose();
});
const handleDelete = useLastCallback(() => {
if (!toneToEdit) return;
const tone = getInputTone(toneToEdit);
deleteAiTone({ tone });
closeDeleteConfirm();
handleClose();
});
const handleTitleChange = useLastCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setTitle(e.target.value);
});
const handlePromptChange = useLastCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value);
});
const handleEmojiSelect = useLastCallback((emojiDocumentId: string) => {
setEmojiId(emojiDocumentId);
closeEmojiPicker();
});
const modalTitle = lang(isEditMode ? 'AiToneEditorEditTitle' : 'AiToneEditorTitle');
const renderHeader = useMemo(() => (
<ModalHeader>
<ModalCloseButton />
<ModalTitle>{modalTitle}</ModalTitle>
</ModalHeader>
), [modalTitle]);
return (
<>
<Modal
isOpen={isOpen}
onClose={handleClose}
header={renderHeader}
ariaLabel={modalTitle}
width="slim"
>
<div className={styles.emojiRow}>
<button
type="button"
className={styles.emojiButton}
onClick={openEmojiPicker}
>
{emojiId ? (
<CustomEmoji documentId={emojiId} size={EMOJI_SIZE} />
) : (
<Icon name="smile" className={styles.emojiPlaceholderIcon} />
)}
</button>
</div>
<Island>
<InputText
className={styles.input}
value={title}
onChange={handleTitleChange}
placeholder={lang('AiToneEditorNamePlaceholder')}
maxLength={titleMaxLength}
hasLengthIndicator
/>
<TextArea
className={buildClassName(styles.input, styles.promptInput)}
value={prompt}
onChange={handlePromptChange}
placeholder={lang('AiToneEditorPromptPlaceholder')}
maxLength={promptMaxLength}
hasLengthIndicator
noReplaceNewlines
/>
</Island>
<Island>
<CheckboxField
label={lang('AiToneEditorDisplayAuthor')}
checked={shouldDisplayAuthor}
isRound
isCentered
onChange={setShouldDisplayAuthor}
/>
</Island>
<div className={styles.footer}>
{isEditMode && (
<Button
className={styles.deleteButton}
onClick={openDeleteConfirm}
color="danger"
isText
>
{lang('AiToneDeleteStyle')}
</Button>
)}
<Button
className={styles.createButton}
onClick={handleSubmit}
disabled={!canSubmit}
>
{lang(isEditMode ? 'Save' : 'Create')}
</Button>
</div>
</Modal>
<AiToneEmojiPickerModal
isOpen={isEmojiPickerOpen}
onEmojiSelect={handleEmojiSelect}
onClose={closeEmojiPicker}
/>
<ConfirmDialog
isOpen={isDeleteConfirmOpen}
title={lang('AiToneDeleteStyle')}
text={lang('AiToneDeleteStyleConfirm')}
confirmLabel={lang('Delete')}
confirmIsDestructive
onClose={closeDeleteConfirm}
confirmHandler={handleDelete}
/>
</>
);
};
export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
toneToEdit: selectTabState(global).aiToneEditorModal?.toneToEdit,
titleMaxLength: global.appConfig.aiComposeToneTitleLengthMax,
promptMaxLength: global.appConfig.aiComposeTonePromptLengthMax,
};
},
)(AiToneEditorModal));

View File

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

View File

@ -0,0 +1,9 @@
.content {
--modal-content-block-padding: 0rem;
overflow: hidden;
}
.picker {
height: var(--symbol-menu-height);
}

View File

@ -0,0 +1,63 @@
import { memo, useMemo } from '../../../../lib/teact/teact';
import type { ApiSticker } from '../../../../api/types';
import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import CustomEmojiPicker from '../../../common/CustomEmojiPicker';
import Modal, { ModalCloseButton, ModalHeader, ModalTitle } from '@gili/modal/Modal';
import styles from './AiToneEmojiPickerModal.module.scss';
export type OwnProps = {
isOpen: boolean;
onEmojiSelect: (emojiId: string) => void;
onClose: NoneToVoidFunction;
};
const AiToneEmojiPickerModal = ({
isOpen,
onEmojiSelect,
onClose,
}: OwnProps) => {
const lang = useLang();
const handleEmojiSelect = useLastCallback((sticker: ApiSticker) => {
onEmojiSelect(sticker.id);
});
const renderHeader = useMemo(() => (
<ModalHeader>
<ModalCloseButton />
<ModalTitle>{lang('AiToneEditorSelectEmoji')}</ModalTitle>
</ModalHeader>
), [lang]);
return (
<Modal
isOpen={isOpen}
onClose={onClose}
header={renderHeader}
ariaLabel={lang('AiToneEditorSelectEmoji')}
width="slim"
noScrollable
noContentInlinePadding
keepMounted
contentClassName={styles.content}
>
<div className={styles.picker}>
<CustomEmojiPicker
idPrefix="ai-tone-icon-"
loadAndPlay={isOpen}
isHidden={!isOpen}
noAddButton
onCustomEmojiSelect={handleEmojiSelect}
onDismiss={onClose}
/>
</div>
</Modal>
);
};
export default memo(AiToneEmojiPickerModal);

View File

@ -197,6 +197,23 @@
} }
} }
&--ai-tone-emoji {
--custom-emoji-size: 3rem;
display: flex;
align-items: center;
justify-content: center;
}
&--ai-tone {
max-width: 20rem;
.WebPage--ai-tone-emoji {
width: 3rem;
height: 3rem;
}
}
@media (min-width: 1921px) { @media (min-width: 1921px) {
max-width: none; max-width: none;

View File

@ -18,6 +18,7 @@ import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback'; import useLastCallback from '../../../hooks/useLastCallback';
import Audio from '../../common/Audio'; import Audio from '../../common/Audio';
import CustomEmoji from '../../common/CustomEmoji';
import Document from '../../common/Document'; import Document from '../../common/Document';
import EmojiIconBackground from '../../common/embedded/EmojiIconBackground'; import EmojiIconBackground from '../../common/embedded/EmojiIconBackground';
import Icon from '../../common/icons/Icon'; import Icon from '../../common/icons/Icon';
@ -37,6 +38,7 @@ const MAX_TEXT_LENGTH = 170; // symbols
const WEBPAGE_STORY_TYPE = 'telegram_story'; const WEBPAGE_STORY_TYPE = 'telegram_story';
const WEBPAGE_GIFT_TYPE = 'telegram_nft'; const WEBPAGE_GIFT_TYPE = 'telegram_nft';
const WEBPAGE_AUCTION_TYPE = 'telegram_auction'; const WEBPAGE_AUCTION_TYPE = 'telegram_auction';
const WEBPAGE_AI_TONE_TYPE = 'telegram_aicomposetone';
const STICKER_SIZE = 80; const STICKER_SIZE = 80;
const EMOJI_SIZE = 38; const EMOJI_SIZE = 38;
@ -148,6 +150,7 @@ const WebPage = ({
const isStory = type === WEBPAGE_STORY_TYPE; const isStory = type === WEBPAGE_STORY_TYPE;
const isGift = type === WEBPAGE_GIFT_TYPE; const isGift = type === WEBPAGE_GIFT_TYPE;
const isAuction = type === WEBPAGE_AUCTION_TYPE; const isAuction = type === WEBPAGE_AUCTION_TYPE;
const isAiTone = type === WEBPAGE_AI_TONE_TYPE;
const isExpiredStory = story && 'isDeleted' in story; const isExpiredStory = story && 'isDeleted' in story;
const resultType = stickers?.isEmoji ? 'telegram_emojiset' : type; const resultType = stickers?.isEmoji ? 'telegram_emojiset' : type;
@ -157,8 +160,9 @@ const WebPage = ({
const quickButtonIcon = getWebpageButtonIcon(resultType); const quickButtonIcon = getWebpageButtonIcon(resultType);
const truncatedDescription = trimText(description, MAX_TEXT_LENGTH); const truncatedDescription = trimText(description, MAX_TEXT_LENGTH);
const aiToneEmojiId = isAiTone ? webPage.aiComposeToneEmojiId : undefined;
const isArticle = Boolean(truncatedDescription || title || siteName); const isArticle = Boolean(truncatedDescription || title || siteName);
let isSquarePhoto = Boolean(stickers); let isSquarePhoto = Boolean(stickers) || Boolean(aiToneEmojiId);
if (isArticle && webPage?.photo && !webPage.video && !webPage.document) { if (isArticle && webPage?.photo && !webPage.video && !webPage.document) {
isSquarePhoto = getIsSmallPhoto(webPage, mediaSize); isSquarePhoto = getIsSmallPhoto(webPage, mediaSize);
} }
@ -173,6 +177,7 @@ const WebPage = ({
document && 'with-document', document && 'with-document',
quickButtonTitle && 'with-quick-button', quickButtonTitle && 'with-quick-button',
(isGift || isAuction) && 'with-gift', (isGift || isAuction) && 'with-gift',
isAiTone && 'WebPage--ai-tone',
); );
function renderQuickButton() { function renderQuickButton() {
@ -323,6 +328,11 @@ const WebPage = ({
))} ))}
</div> </div>
)} )}
{aiToneEmojiId && (
<div className="media-inner square-image WebPage--ai-tone-emoji">
<CustomEmoji documentId={aiToneEmojiId} size={STICKER_SIZE} />
</div>
)}
</div> </div>
{quickButtonTitle && renderQuickButton()} {quickButtonTitle && renderQuickButton()}
</PeerColorWrapper> </PeerColorWrapper>

View File

@ -44,6 +44,8 @@ export function getWebpageButtonLangKey(type?: string, auctionEndDate?: number):
const isFinished = auctionEndDate !== undefined && auctionEndDate < getServerTime(); const isFinished = auctionEndDate !== undefined && auctionEndDate < getServerTime();
return isFinished ? 'PollViewResults' : 'GiftAuctionJoin'; return isFinished ? 'PollViewResults' : 'GiftAuctionJoin';
} }
case 'telegram_aicomposetone':
return 'ViewButtonAiStyle';
default: default:
return undefined; return undefined;
} }

View File

@ -14,6 +14,7 @@ import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal
import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async'; import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async';
import AboutAdsModal from './aboutAds/AboutAdsModal.async'; import AboutAdsModal from './aboutAds/AboutAdsModal.async';
import AgeVerificationModal from './ageVerification/AgeVerificationModal.async'; import AgeVerificationModal from './ageVerification/AgeVerificationModal.async';
import AiTonePreviewModal from './aiTonePreview/AiTonePreviewModal.async';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async'; import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
import BirthdaySetupModal from './birthday/BirthdaySetupModal.async'; import BirthdaySetupModal from './birthday/BirthdaySetupModal.async';
import BoostModal from './boost/BoostModal.async'; import BoostModal from './boost/BoostModal.async';
@ -157,7 +158,8 @@ type ModalKey = keyof Pick<TabState,
'isQuickChatPickerOpen' | 'isQuickChatPickerOpen' |
'isCocoonModalOpen' | 'isCocoonModalOpen' |
'editRankModal' | 'editRankModal' |
'rankModal' 'rankModal' |
'aiTonePreviewModal'
>; >;
type WrappedModalKey = 'pollModal'; type WrappedModalKey = 'pollModal';
type LegacyModalKey = Exclude<ModalKey, WrappedModalKey>; type LegacyModalKey = Exclude<ModalKey, WrappedModalKey>;
@ -293,6 +295,7 @@ const LEGACY_MODALS: LegacyModalRegistry = {
isCocoonModalOpen: CocoonModal, isCocoonModalOpen: CocoonModal,
editRankModal: EditRankModal, editRankModal: EditRankModal,
rankModal: RankModal, rankModal: RankModal,
aiTonePreviewModal: AiTonePreviewModal,
}; };
const WRAPPED_MODALS: WrappedModalRegistry = { const WRAPPED_MODALS: WrappedModalRegistry = {
pollModal: PollModal, pollModal: PollModal,

View File

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

View File

@ -0,0 +1,116 @@
.modal {
--modal-header-height: 1rem;
--modal-max-height: min(39.3125rem, 80dvh);
}
.emojiRow {
display: flex;
justify-content: center;
}
.emojiCircle {
--custom-emoji-size: 3rem;
display: flex;
align-items: center;
justify-content: center;
width: 5rem;
height: 5rem;
}
.title {
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
text-align: center;
}
.subtitle {
max-width: 13rem;
margin-top: 0.25rem;
margin-inline: auto;
font-size: 0.875rem;
color: var(--color-text-secondary);
text-align: center;
}
.exampleWrapper {
margin-top: 1rem;
padding: 1rem;
border-radius: 1rem;
background: var(--color-background);
}
.skeletonBefore {
margin-bottom: 1.0625rem;
}
.skeletonAfter {
margin-top: 0.9375rem;
}
.anotherExampleButton {
cursor: pointer;
display: flex;
gap: 0.25rem;
align-items: center;
min-height: 1.75rem;
margin-inline-start: auto;
padding: 0.25rem 0.625rem;
border: none;
border-radius: 1rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
line-height: 1.25rem;
color: var(--color-primary);
background: var(--color-background-secondary);
outline: none;
transition: opacity 0.15s;
&:hover {
opacity: 0.75;
}
}
.anotherExampleIcon {
font-size: 0.9375rem;
transition: transform 0.3s ease;
}
.info {
margin-top: 1rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
text-align: center;
}
.footer {
position: relative;
display: flex;
padding: 0 1rem 1rem;
&::before {
pointer-events: none;
content: "";
position: absolute;
bottom: 100%;
left: 0;
width: 100%;
height: 1.125rem;
background: linear-gradient(to top, var(--color-background-secondary) 0%, transparent 100%);
}
}
.addButton {
flex: 1;
border-radius: 2rem;
}

View File

@ -0,0 +1,277 @@
import { memo, useMemo, useRef, useState } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ApiUser } from '../../../api/types';
import type { TabState } from '../../../global/types';
import { selectUser } from '../../../global/selectors';
import { getInputTone } from '../../../util/aiComposeTones';
import calcTextLineHeightAndCount from '../../../util/element/calcTextLineHeightAndCount';
import formatUsername from '../../common/helpers/formatUsername';
import { renderTextWithEntities } from '../../common/helpers/renderTextWithEntities';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import CustomEmoji from '../../common/CustomEmoji';
import Icon from '../../common/icons/Icon';
import { AiEditorResultArea } from '../../middle/composer/AiMessageEditorModal/AiEditorShared';
import Button from '../../ui/Button';
import Link from '../../ui/Link';
import TextLoadingPlaceholder from '../../ui/placeholder/TextLoadingPlaceholder';
import Modal, { ModalCloseButton, ModalHeader } from '@gili/modal/Modal';
import sharedStyles from '../../middle/composer/AiMessageEditorModal/AiEditorShared.module.scss';
import styles from './AiTonePreviewModal.module.scss';
const DEFAULT_MAX_EXAMPLES = 3;
const DEFAULT_LINES = 4;
const EMOJI_SIZE = 48;
export type OwnProps = {
modal: TabState['aiTonePreviewModal'];
};
type StateProps = {
author?: ApiUser;
maxExamples?: number;
};
const AiTonePreviewModal = ({ modal, author, maxExamples = DEFAULT_MAX_EXAMPLES }: OwnProps & StateProps) => {
const {
closeAiTonePreview,
saveAiTone,
loadAiTonePreviewExample,
openChat,
} = getActions();
const lang = useLang();
const isOpen = Boolean(modal);
const tone = modal?.tone;
const example = modal?.example;
const isAlreadyAdded = modal?.isAlreadyAdded;
const slug = modal?.slug;
const hasExampleError = modal?.hasExampleError;
const [exampleNum, setExampleNum] = useState(0);
const spinCountRef = useRef(0);
const beforeRef = useRef<HTMLDivElement>();
const afterRef = useRef<HTMLDivElement>();
const prevLinesRef = useRef({ before: DEFAULT_LINES, after: DEFAULT_LINES });
const handleClose = useLastCallback(() => {
closeAiTonePreview();
setExampleNum(0);
spinCountRef.current = 0;
prevLinesRef.current = { before: DEFAULT_LINES, after: DEFAULT_LINES };
});
const handleAdd = useLastCallback(() => {
if (!tone) return;
saveAiTone({ tone: getInputTone(tone) });
});
const handleRemove = useLastCallback(() => {
if (!tone) return;
saveAiTone({ tone: getInputTone(tone), unsave: true });
});
const handleAnotherExample = useLastCallback(() => {
if (!slug) return;
if (beforeRef.current && afterRef.current) {
prevLinesRef.current = {
before: Math.max(DEFAULT_LINES, calcTextLineHeightAndCount(beforeRef.current).totalLines),
after: Math.max(DEFAULT_LINES, calcTextLineHeightAndCount(afterRef.current).totalLines),
};
}
const nextNum = (exampleNum + 1) % maxExamples;
spinCountRef.current += 1;
setExampleNum(nextNum);
loadAiTonePreviewExample({
tone: { type: 'slug', slug },
num: nextNum,
});
});
const handleAuthorClick = useLastCallback(() => {
if (!tone?.authorId) return;
handleClose();
openChat({ id: tone.authorId });
});
const renderHeader = useMemo(() => (
<ModalHeader>
<ModalCloseButton />
</ModalHeader>
), []);
function renderFooterInfo() {
if (!tone) return undefined;
const installsCount = tone.installsCount || 0;
const authorName = author?.usernames?.[0]?.username;
if (!installsCount && !authorName) return undefined;
const authorLink = authorName
? <Link isPrimary onClick={handleAuthorClick}>{formatUsername(authorName)}</Link>
: undefined;
const usedByText = installsCount
? lang('AiTonePreviewUsedBy', { count: lang.number(installsCount) })
: undefined;
const createdByText = authorLink
? lang('AiTonePreviewCreatedBy', { author: authorLink }, { withNodes: true })
: undefined;
if (usedByText && createdByText) {
return (
<div className={styles.info}>
{lang('AiTonePreviewUsedByCreatedBy', {
usedBy: usedByText,
createdBy: createdByText,
}, { withNodes: true })}
</div>
);
}
return (
<div className={styles.info}>
{usedByText || createdByText}
</div>
);
}
const { before: beforeLines, after: afterLines } = prevLinesRef.current;
const exampleLoadingElement = useMemo(() => (
<>
<TextLoadingPlaceholder lines={beforeLines} className={styles.skeletonBefore} />
<div className={sharedStyles.separator} />
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>{lang('AiTonePreviewAfter')}</span>
</div>
<TextLoadingPlaceholder lines={afterLines} className={styles.skeletonAfter} />
</>
), [lang, beforeLines, afterLines]);
function renderExampleContent() {
if (hasExampleError) {
return (
<div className={sharedStyles.errorMessage}>
{lang('AiMessageEditorGenericError')}
</div>
);
}
if (!example) return undefined;
return (
<>
<div ref={beforeRef} className={sharedStyles.resultContent}>
{renderTextWithEntities(example.from)}
</div>
<div className={sharedStyles.separator} />
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>{lang('AiTonePreviewAfter')}</span>
</div>
<div ref={afterRef} className={sharedStyles.resultContent}>
{renderTextWithEntities(example.to)}
</div>
</>
);
}
const renderFooter = useMemo(() => {
if (!tone) return undefined;
return (
<div className={styles.footer}>
{isAlreadyAdded ? (
<Button
className={styles.addButton}
onClick={tone.isCreator ? handleClose : handleRemove}
color={tone.isCreator ? undefined : 'danger'}
isText={!tone.isCreator}
noForcedUpperCase
>
{lang(tone.isCreator ? 'Done' : 'AiTonePreviewRemoveStyle')}
</Button>
) : (
<Button
className={styles.addButton}
onClick={handleAdd}
>
{lang('AiTonePreviewAddStyle')}
</Button>
)}
</div>
);
}, [tone, isAlreadyAdded, lang, handleClose, handleRemove, handleAdd]);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
header={renderHeader}
stickyFooter={renderFooter}
dialogClassName={styles.modal}
width="slim"
height="regular"
>
{tone && (
<>
{tone.emojiId && (
<div className={styles.emojiRow}>
<div className={styles.emojiCircle}>
<CustomEmoji documentId={tone.emojiId} size={EMOJI_SIZE} />
</div>
</div>
)}
<div className={styles.title}>{tone.title}</div>
<div className={styles.subtitle}>{lang('AiTonePreviewSubtitle')}</div>
<div className={styles.exampleWrapper}>
<div className={sharedStyles.labelRow}>
<span className={sharedStyles.label}>{lang('AiTonePreviewBefore')}</span>
<button
type="button"
className={styles.anotherExampleButton}
onClick={handleAnotherExample}
>
<Icon
name="reload-arrows"
className={styles.anotherExampleIcon}
style={`transform: rotate(${spinCountRef.current * 180}deg)`}
/>
{lang('AiTonePreviewAnotherExample')}
</button>
</div>
<AiEditorResultArea
isLoading={!example && !hasExampleError}
transitionKey={exampleNum}
loadingElement={exampleLoadingElement}
>
{renderExampleContent()}
</AiEditorResultArea>
</div>
{renderFooterInfo()}
</>
)}
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): Complete<StateProps> => {
const authorId = modal?.tone?.authorId;
return {
author: authorId ? selectUser(global, authorId) : undefined,
maxExamples: global.appConfig.aiComposeToneExamplesNum,
};
},
)(AiTonePreviewModal));

View File

@ -9,6 +9,8 @@ import buildClassName from '../../util/buildClassName';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import AnimatedCounter from '../common/AnimatedCounter';
type OwnProps = { type OwnProps = {
ref?: ElementRef<HTMLInputElement>; ref?: ElementRef<HTMLInputElement>;
id?: string; id?: string;
@ -22,6 +24,7 @@ type OwnProps = {
placeholder?: string; placeholder?: string;
autoComplete?: string; autoComplete?: string;
maxLength?: number; maxLength?: number;
hasLengthIndicator?: boolean;
tabIndex?: number; tabIndex?: number;
title?: string; title?: string;
autoFocus?: boolean; autoFocus?: boolean;
@ -51,6 +54,7 @@ const InputText = ({
autoComplete = 'off', autoComplete = 'off',
inputMode, inputMode,
maxLength, maxLength,
hasLengthIndicator,
tabIndex, tabIndex,
title, title,
autoFocus, autoFocus,
@ -109,6 +113,11 @@ const InputText = ({
{labelText && ( {labelText && (
<label htmlFor={id}>{labelText}</label> <label htmlFor={id}>{labelText}</label>
)} )}
{hasLengthIndicator && maxLength !== undefined && (
<div className="max-length-indicator">
<AnimatedCounter text={Math.max(0, maxLength - (value || '').length).toString()} />
</div>
)}
</div> </div>
); );
}; };

View File

@ -12,6 +12,8 @@ import buildClassName from '../../util/buildClassName';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import AnimatedCounter from '../common/AnimatedCounter';
type OwnProps = { type OwnProps = {
ref?: ElementRef<HTMLTextAreaElement>; ref?: ElementRef<HTMLTextAreaElement>;
id?: string; id?: string;
@ -26,6 +28,7 @@ type OwnProps = {
autoComplete?: string; autoComplete?: string;
maxLength?: number; maxLength?: number;
maxLengthIndicator?: string; maxLengthIndicator?: string;
hasLengthIndicator?: boolean;
tabIndex?: number; tabIndex?: number;
inputMode?: 'text' | 'none' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; inputMode?: 'text' | 'none' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search';
onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void; onChange?: (e: ChangeEvent<HTMLTextAreaElement>) => void;
@ -52,6 +55,7 @@ const TextArea: FC<OwnProps> = ({
inputMode, inputMode,
maxLength, maxLength,
maxLengthIndicator, maxLengthIndicator,
hasLengthIndicator,
tabIndex, tabIndex,
onChange, onChange,
onInput, onInput,
@ -135,8 +139,12 @@ const TextArea: FC<OwnProps> = ({
{labelText && ( {labelText && (
<label htmlFor={id}>{labelText}</label> <label htmlFor={id}>{labelText}</label>
)} )}
{maxLengthIndicator && ( {(maxLengthIndicator || (hasLengthIndicator && maxLength !== undefined)) && (
<div className="max-length-indicator">{maxLengthIndicator}</div> <div className="max-length-indicator">
<AnimatedCounter
text={maxLengthIndicator || Math.max(0, maxLength! - (value || '').length).toString()}
/>
</div>
)} )}
</div> </div>
); );

View File

@ -1,4 +1,5 @@
import type { ApiInputAiComposeTone } from '../../../api/types'; import type { ApiInputAiComposeTone } from '../../../api/types';
import type { ActionReturnType, GlobalState } from '../../types';
import { compareAiTones, getToneCacheKey } from '../../../util/aiComposeTones'; import { compareAiTones, getToneCacheKey } from '../../../util/aiComposeTones';
import { getCurrentTabId } from '../../../util/establishMultitabRole'; import { getCurrentTabId } from '../../../util/establishMultitabRole';
@ -6,6 +7,36 @@ import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { updateTabState } from '../../reducers/tabs'; import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors'; import { selectTabState } from '../../selectors';
import { selectCurrentLimit } from '../../selectors/limits';
import { selectIsCurrentUserPremium } from '../../selectors/users';
export function showToneLimitNotification<T extends GlobalState>(
global: T,
actions: { showNotification: AnyFunction },
tabId: number,
): boolean {
const isPremium = selectIsCurrentUserPremium(global);
const limit = selectCurrentLimit(global, 'aiComposeToneSaved');
const customToneCount = (global.aiComposeTones?.tones || []).filter((t) => 'id' in t).length;
if (customToneCount < limit) return false;
if (isPremium) {
actions.showNotification({
message: { key: 'AiToneLimitReachedPremium', variables: { limit: limit.toString() } },
tabId,
});
} else {
actions.showNotification({
message: { key: 'AiToneLimitReached' },
action: { action: 'openPremiumModal', payload: { tabId } },
actionText: { key: 'PremiumMore' },
tabId,
});
}
return true;
}
function buildStyleCacheKey(tone?: ApiInputAiComposeTone, emojify?: boolean) { function buildStyleCacheKey(tone?: ApiInputAiComposeTone, emojify?: boolean) {
return `${tone ? getToneCacheKey(tone) : ''}_${emojify ? '1' : '0'}`; return `${tone ? getToneCacheKey(tone) : ''}_${emojify ? '1' : '0'}`;
@ -148,6 +179,166 @@ addActionHandler('composeWithAiMessageEditor', async (global, actions, payload):
setGlobal(global); setGlobal(global);
}); });
addActionHandler('createAiTone', async (global, actions, payload): Promise<void> => {
const {
title, emojiId, prompt, shouldDisplayAuthor,
tabId = getCurrentTabId(),
} = payload;
if (showToneLimitNotification(global, actions, tabId)) return;
const result = await callApi('createAiTone', {
title, emojiId, prompt, shouldDisplayAuthor,
});
if (!result) return;
actions.closeAiToneEditorModal({ tabId });
actions.loadAiComposeTones();
actions.showNotification({
title: { key: 'AiToneCreated', variables: { title } },
message: { key: 'AiToneCreatedHint' },
customEmojiIconId: emojiId,
tabId,
});
});
addActionHandler('deleteAiTone', async (global, actions, payload): Promise<void> => {
const { tone, tabId = getCurrentTabId() } = payload;
const result = await callApi('deleteAiTone', { tone });
if (!result) {
actions.showNotification({ message: { key: 'ErrorUnspecified' }, tabId });
return;
}
actions.loadAiComposeTones();
});
addActionHandler('updateAiTone', async (global, actions, payload): Promise<void> => {
const {
tone, title, emojiId, prompt, shouldDisplayAuthor,
tabId = getCurrentTabId(),
} = payload;
const updatedTone = await callApi('updateAiTone', {
tone, title, emojiId, prompt, shouldDisplayAuthor,
});
if (!updatedTone) return;
global = getGlobal();
const currentTones = global.aiComposeTones?.tones || [];
const updatedTones = 'id' in updatedTone
? currentTones.map((t) => ('id' in t && t.id === updatedTone.id ? updatedTone : t))
: currentTones;
global = {
...global,
aiComposeTones: {
...global.aiComposeTones,
tones: updatedTones,
hash: global.aiComposeTones?.hash || '',
},
};
setGlobal(global);
actions.closeAiToneEditorModal({ tabId });
actions.loadAiComposeTones();
});
addActionHandler('openAiTonePreview', async (global, actions, payload): Promise<void> => {
const { slug, tabId = getCurrentTabId() } = payload;
const result = await callApi('fetchAiTone', {
tone: { type: 'slug', slug },
});
if (!result?.tones.length) {
actions.showNotification({ message: { key: 'ErrorUnspecified' }, tabId });
return;
}
const tone = result.tones[0];
if (!('id' in tone)) return;
const example = await callApi('fetchAiToneExample', {
tone: { type: 'slug', slug },
num: 0,
});
global = getGlobal();
const currentTones = global.aiComposeTones?.tones || [];
const isAlreadyAdded = tone.isCreator || currentTones.some((t) => 'id' in t && t.id === tone.id);
global = updateTabState(global, {
aiTonePreviewModal: {
slug,
tone,
isAlreadyAdded,
example,
},
}, tabId);
setGlobal(global);
});
addActionHandler('closeAiTonePreview', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aiTonePreviewModal: undefined,
}, tabId);
});
addActionHandler('saveAiTone', async (global, actions, payload): Promise<void> => {
const { tone, unsave, tabId = getCurrentTabId() } = payload;
if (!unsave && showToneLimitNotification(global, actions, tabId)) return;
const result = await callApi('saveAiTone', { tone, unsave });
if (!result) return;
actions.loadAiComposeTones();
actions.closeAiTonePreview({ tabId });
if (!unsave) {
actions.showNotification({
message: { key: 'AiTonePreviewStyleAdded' },
tabId,
});
}
});
addActionHandler('loadAiTonePreviewExample', async (global, actions, payload): Promise<void> => {
const { tone, num, tabId = getCurrentTabId() } = payload;
// Clear current example to trigger loading state
const currentModal = selectTabState(global, tabId).aiTonePreviewModal;
if (currentModal) {
global = updateTabState(global, {
aiTonePreviewModal: { ...currentModal, example: undefined, hasExampleError: undefined },
}, tabId);
setGlobal(global);
}
const example = await callApi('fetchAiToneExample', { tone, num });
global = getGlobal();
const previewModal = selectTabState(global, tabId).aiTonePreviewModal;
if (!previewModal) return;
const openModalTone: ApiInputAiComposeTone = { type: 'slug', slug: previewModal.slug };
if (!compareAiTones(openModalTone, tone)) return;
global = updateTabState(global, {
aiTonePreviewModal: {
...previewModal,
example,
hasExampleError: !example,
},
}, tabId);
setGlobal(global);
});
addActionHandler('loadAiComposeTones', async (global): Promise<void> => { addActionHandler('loadAiComposeTones', async (global): Promise<void> => {
const hash = global.aiComposeTones?.hash; const hash = global.aiComposeTones?.hash;
const result = await callApi('fetchAiComposeTones', { hash }); const result = await callApi('fetchAiComposeTones', { hash });

View File

@ -1754,6 +1754,12 @@ addActionHandler('openTelegramLink', async (global, actions, payload): Promise<v
return; return;
} }
if (part1 === 'addstyle') {
if (!part2) return;
actions.openAiTonePreview({ slug: part2, tabId });
return;
}
if (part1 === 'share') { if (part1 === 'share') {
const text = formatShareText(params.url, params.text); const text = formatShareText(params.url, params.text);
openChatWithDraft({ text, tabId }); openChatWithDraft({ text, tabId });

View File

@ -6,6 +6,7 @@ import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors'; import { selectTabState } from '../../selectors';
import { selectCurrentMessageList } from '../../selectors/messages'; import { selectCurrentMessageList } from '../../selectors/messages';
import { selectTranslationLanguage } from '../../selectors/settings'; import { selectTranslationLanguage } from '../../selectors/settings';
import { showToneLimitNotification } from '../api/ai';
addActionHandler('openAiMessageEditorModal', (global, actions, payload): ActionReturnType => { addActionHandler('openAiMessageEditorModal', (global, actions, payload): ActionReturnType => {
const { const {
@ -187,3 +188,23 @@ addActionHandler('clearAiMessageEditorPendingResult', (global, actions, payload)
aiMessageEditorPendingResult: undefined, aiMessageEditorPendingResult: undefined,
}, tabId); }, tabId);
}); });
addActionHandler('openAiToneEditorModal', (global, actions, payload): ActionReturnType => {
const { toneToEdit, tabId = getCurrentTabId() } = payload || {};
if (!toneToEdit && showToneLimitNotification(global, actions, tabId)) {
return undefined;
}
return updateTabState(global, {
aiToneEditorModal: { toneToEdit },
}, tabId);
});
addActionHandler('closeAiToneEditorModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
aiToneEditorModal: undefined,
}, tabId);
});

View File

@ -1,4 +1,5 @@
import type { import type {
ApiAiComposeTone,
ApiAttachBot, ApiAttachBot,
ApiAttachment, ApiAttachment,
ApiBirthday, ApiBirthday,
@ -2694,6 +2695,38 @@ export interface ActionPayloads {
scheduleRepeatPeriod?: number; scheduleRepeatPeriod?: number;
} & WithTabId) | undefined; } & WithTabId) | undefined;
clearAiMessageEditorPendingResult: WithTabId | undefined; clearAiMessageEditorPendingResult: WithTabId | undefined;
openAiToneEditorModal: {
toneToEdit?: ApiAiComposeTone;
} & WithTabId | undefined;
closeAiToneEditorModal: WithTabId | undefined;
createAiTone: {
title: string;
emojiId: string;
prompt: string;
shouldDisplayAuthor?: boolean;
} & WithTabId;
updateAiTone: {
tone: ApiInputAiComposeTone;
title?: string;
emojiId?: string;
prompt?: string;
shouldDisplayAuthor?: boolean;
} & WithTabId;
deleteAiTone: {
tone: ApiInputAiComposeTone;
} & WithTabId;
openAiTonePreview: {
slug: string;
} & WithTabId;
closeAiTonePreview: WithTabId | undefined;
saveAiTone: {
tone: ApiInputAiComposeTone;
unsave?: boolean;
} & WithTabId;
loadAiTonePreviewExample: {
tone: ApiInputAiComposeTone;
num: number;
} & WithTabId;
openGiveawayModal: ({ openGiveawayModal: ({
chatId: string; chatId: string;

View File

@ -1,4 +1,6 @@
import type { import type {
ApiAiComposeTone,
ApiAiComposeToneExample,
ApiAttachBot, ApiAttachBot,
ApiBirthday, ApiBirthday,
ApiBoost, ApiBoost,
@ -694,6 +696,18 @@ export type TabState = {
}; };
}; };
aiToneEditorModal?: {
toneToEdit?: ApiAiComposeTone;
};
aiTonePreviewModal?: {
slug: string;
tone?: ApiAiComposeTone;
example?: ApiAiComposeToneExample;
isAlreadyAdded?: boolean;
hasExampleError?: boolean;
};
aiMessageEditorPendingResult?: { aiMessageEditorPendingResult?: {
text?: ApiFormattedText; text?: ApiFormattedText;
shouldClear?: boolean; shouldClear?: boolean;

View File

@ -34,6 +34,7 @@ export const DEFAULT_LIMITS: Record<ApiLimitType, readonly [number, number]> = {
savedDialogsPinned: [5, 100], savedDialogsPinned: [5, 100],
maxReactions: [1, 3], maxReactions: [1, 3],
moreAccounts: [3, MULTIACCOUNT_MAX_SLOTS], moreAccounts: [3, MULTIACCOUNT_MAX_SLOTS],
aiComposeToneSaved: [5, 20],
}; };
export const DEFAULT_MAX_MESSAGE_LENGTH = 4096; export const DEFAULT_MAX_MESSAGE_LENGTH = 4096;
@ -58,6 +59,7 @@ export const DEFAULT_APP_CONFIG: ApiAppConfig = {
savedDialogsPinned: DEFAULT_LIMITS.savedDialogsPinned, savedDialogsPinned: DEFAULT_LIMITS.savedDialogsPinned,
moreAccounts: DEFAULT_LIMITS.moreAccounts, moreAccounts: DEFAULT_LIMITS.moreAccounts,
maxReactions: DEFAULT_LIMITS.maxReactions, maxReactions: DEFAULT_LIMITS.maxReactions,
aiComposeToneSaved: DEFAULT_LIMITS.aiComposeToneSaved,
}, },
autologinDomains: [ autologinDomains: [
'instantview.telegram.org', 'instantview.telegram.org',

View File

@ -3,8 +3,8 @@
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: block; font-display: block;
src: url("./icons.woff2?e9a7be346eaac71f831beea8710b79fd") format("woff2"), src: url("./icons.woff2?2ae8509ab058d7ef62dd8b67cc2b2b75") format("woff2"),
url("./icons.woff?e9a7be346eaac71f831beea8710b79fd") format("woff"); url("./icons.woff?2ae8509ab058d7ef62dd8b67cc2b2b75") format("woff");
} }
.icon-char::before { .icon-char::before {
@ -720,324 +720,327 @@ url("./icons.woff?e9a7be346eaac71f831beea8710b79fd") format("woff");
.icon-reload::before { .icon-reload::before {
content: "\f1e9"; content: "\f1e9";
} }
.icon-remove::before { .icon-reload-arrows::before {
content: "\f1ea"; content: "\f1ea";
} }
.icon-remove-quote::before { .icon-remove::before {
content: "\f1eb"; content: "\f1eb";
} }
.icon-reopen-topic::before { .icon-remove-quote::before {
content: "\f1ec"; content: "\f1ec";
} }
.icon-reorder-tabs::before { .icon-reopen-topic::before {
content: "\f1ed"; content: "\f1ed";
} }
.icon-replace::before { .icon-reorder-tabs::before {
content: "\f1ee"; content: "\f1ee";
} }
.icon-replace-round::before { .icon-replace::before {
content: "\f1ef"; content: "\f1ef";
} }
.icon-replies::before { .icon-replace-round::before {
content: "\f1f0"; content: "\f1f0";
} }
.icon-reply::before { .icon-replies::before {
content: "\f1f1"; content: "\f1f1";
} }
.icon-reply-filled::before { .icon-reply::before {
content: "\f1f2"; content: "\f1f2";
} }
.icon-revenue-split::before { .icon-reply-filled::before {
content: "\f1f3"; content: "\f1f3";
} }
.icon-revote::before { .icon-revenue-split::before {
content: "\f1f4"; content: "\f1f4";
} }
.icon-rotate::before { .icon-revote::before {
content: "\f1f5"; content: "\f1f5";
} }
.icon-save-story::before { .icon-rotate::before {
content: "\f1f6"; content: "\f1f6";
} }
.icon-saved-messages::before { .icon-save-story::before {
content: "\f1f7"; content: "\f1f7";
} }
.icon-schedule::before { .icon-saved-messages::before {
content: "\f1f8"; content: "\f1f8";
} }
.icon-scheduled::before { .icon-schedule::before {
content: "\f1f9"; content: "\f1f9";
} }
.icon-sd-photo::before { .icon-scheduled::before {
content: "\f1fa"; content: "\f1fa";
} }
.icon-search::before { .icon-sd-photo::before {
content: "\f1fb"; content: "\f1fb";
} }
.icon-select::before { .icon-search::before {
content: "\f1fc"; content: "\f1fc";
} }
.icon-select-filled::before { .icon-select::before {
content: "\f1fd"; content: "\f1fd";
} }
.icon-sell::before { .icon-select-filled::before {
content: "\f1fe"; content: "\f1fe";
} }
.icon-sell-outline::before { .icon-sell::before {
content: "\f1ff"; content: "\f1ff";
} }
.icon-send::before { .icon-sell-outline::before {
content: "\f200"; content: "\f200";
} }
.icon-send-outline::before { .icon-send::before {
content: "\f201"; content: "\f201";
} }
.icon-settings::before { .icon-send-outline::before {
content: "\f202"; content: "\f202";
} }
.icon-settings-filled::before { .icon-settings::before {
content: "\f203"; content: "\f203";
} }
.icon-share-filled::before { .icon-settings-filled::before {
content: "\f204"; content: "\f204";
} }
.icon-share-screen::before { .icon-share-filled::before {
content: "\f205"; content: "\f205";
} }
.icon-share-screen-outlined::before { .icon-share-screen::before {
content: "\f206"; content: "\f206";
} }
.icon-share-screen-stop::before { .icon-share-screen-outlined::before {
content: "\f207"; content: "\f207";
} }
.icon-show-message::before { .icon-share-screen-stop::before {
content: "\f208"; content: "\f208";
} }
.icon-sidebar::before { .icon-show-message::before {
content: "\f209"; content: "\f209";
} }
.icon-skip-next::before { .icon-sidebar::before {
content: "\f20a"; content: "\f20a";
} }
.icon-skip-previous::before { .icon-skip-next::before {
content: "\f20b"; content: "\f20b";
} }
.icon-smallscreen::before { .icon-skip-previous::before {
content: "\f20c"; content: "\f20c";
} }
.icon-smile::before { .icon-smallscreen::before {
content: "\f20d"; content: "\f20d";
} }
.icon-sort::before { .icon-smile::before {
content: "\f20e"; content: "\f20e";
} }
.icon-sort-by-date::before { .icon-sort::before {
content: "\f20f"; content: "\f20f";
} }
.icon-sort-by-number::before { .icon-sort-by-date::before {
content: "\f210"; content: "\f210";
} }
.icon-sort-by-price::before { .icon-sort-by-number::before {
content: "\f211"; content: "\f211";
} }
.icon-speaker::before { .icon-sort-by-price::before {
content: "\f212"; content: "\f212";
} }
.icon-speaker-muted-story::before { .icon-speaker::before {
content: "\f213"; content: "\f213";
} }
.icon-speaker-outline::before { .icon-speaker-muted-story::before {
content: "\f214"; content: "\f214";
} }
.icon-speaker-story::before { .icon-speaker-outline::before {
content: "\f215"; content: "\f215";
} }
.icon-spoiler::before { .icon-speaker-story::before {
content: "\f216"; content: "\f216";
} }
.icon-spoiler-disable::before { .icon-spoiler::before {
content: "\f217"; content: "\f217";
} }
.icon-sport::before { .icon-spoiler-disable::before {
content: "\f218"; content: "\f218";
} }
.icon-star::before { .icon-sport::before {
content: "\f219"; content: "\f219";
} }
.icon-stars-lock::before { .icon-star::before {
content: "\f21a"; content: "\f21a";
} }
.icon-stars-refund::before { .icon-stars-lock::before {
content: "\f21b"; content: "\f21b";
} }
.icon-stats::before { .icon-stars-refund::before {
content: "\f21c"; content: "\f21c";
} }
.icon-stealth-future::before { .icon-stats::before {
content: "\f21d"; content: "\f21d";
} }
.icon-stealth-past::before { .icon-stealth-future::before {
content: "\f21e"; content: "\f21e";
} }
.icon-stickers::before { .icon-stealth-past::before {
content: "\f21f"; content: "\f21f";
} }
.icon-stop::before { .icon-stickers::before {
content: "\f220"; content: "\f220";
} }
.icon-stop-raising-hand::before { .icon-stop::before {
content: "\f221"; content: "\f221";
} }
.icon-story-caption::before { .icon-stop-raising-hand::before {
content: "\f222"; content: "\f222";
} }
.icon-story-expired::before { .icon-story-caption::before {
content: "\f223"; content: "\f223";
} }
.icon-story-priority::before { .icon-story-expired::before {
content: "\f224"; content: "\f224";
} }
.icon-story-reply::before { .icon-story-priority::before {
content: "\f225"; content: "\f225";
} }
.icon-strikethrough::before { .icon-story-reply::before {
content: "\f226"; content: "\f226";
} }
.icon-tag::before { .icon-strikethrough::before {
content: "\f227"; content: "\f227";
} }
.icon-tag-add::before { .icon-tag::before {
content: "\f228"; content: "\f228";
} }
.icon-tag-crossed::before { .icon-tag-add::before {
content: "\f229"; content: "\f229";
} }
.icon-tag-filter::before { .icon-tag-crossed::before {
content: "\f22a"; content: "\f22a";
} }
.icon-tag-name::before { .icon-tag-filter::before {
content: "\f22b"; content: "\f22b";
} }
.icon-timer::before { .icon-tag-name::before {
content: "\f22c"; content: "\f22c";
} }
.icon-timer-filled::before { .icon-timer::before {
content: "\f22d"; content: "\f22d";
} }
.icon-toncoin::before { .icon-timer-filled::before {
content: "\f22e"; content: "\f22e";
} }
.icon-tone::before { .icon-toncoin::before {
content: "\f22f"; content: "\f22f";
} }
.icon-tools::before { .icon-tone::before {
content: "\f230"; content: "\f230";
} }
.icon-topic-new::before { .icon-tools::before {
content: "\f231"; content: "\f231";
} }
.icon-trade::before { .icon-topic-new::before {
content: "\f232"; content: "\f232";
} }
.icon-transcribe::before { .icon-trade::before {
content: "\f233"; content: "\f233";
} }
.icon-truck::before { .icon-transcribe::before {
content: "\f234"; content: "\f234";
} }
.icon-unarchive::before { .icon-truck::before {
content: "\f235"; content: "\f235";
} }
.icon-underlined::before { .icon-unarchive::before {
content: "\f236"; content: "\f236";
} }
.icon-understood::before { .icon-underlined::before {
content: "\f237"; content: "\f237";
} }
.icon-undo::before { .icon-understood::before {
content: "\f238"; content: "\f238";
} }
.icon-unique-profile::before { .icon-undo::before {
content: "\f239"; content: "\f239";
} }
.icon-unlist::before { .icon-unique-profile::before {
content: "\f23a"; content: "\f23a";
} }
.icon-unlist-outline::before { .icon-unlist::before {
content: "\f23b"; content: "\f23b";
} }
.icon-unlock::before { .icon-unlist-outline::before {
content: "\f23c"; content: "\f23c";
} }
.icon-unlock-badge::before { .icon-unlock::before {
content: "\f23d"; content: "\f23d";
} }
.icon-unmute::before { .icon-unlock-badge::before {
content: "\f23e"; content: "\f23e";
} }
.icon-unpin::before { .icon-unmute::before {
content: "\f23f"; content: "\f23f";
} }
.icon-unread::before { .icon-unpin::before {
content: "\f240"; content: "\f240";
} }
.icon-up::before { .icon-unread::before {
content: "\f241"; content: "\f241";
} }
.icon-user::before { .icon-up::before {
content: "\f242"; content: "\f242";
} }
.icon-user-filled::before { .icon-user::before {
content: "\f243"; content: "\f243";
} }
.icon-user-online::before { .icon-user-filled::before {
content: "\f244"; content: "\f244";
} }
.icon-user-stars::before { .icon-user-online::before {
content: "\f245"; content: "\f245";
} }
.icon-user-tag::before { .icon-user-stars::before {
content: "\f246"; content: "\f246";
} }
.icon-video::before { .icon-user-tag::before {
content: "\f247"; content: "\f247";
} }
.icon-video-outlined::before { .icon-video::before {
content: "\f248"; content: "\f248";
} }
.icon-video-stop::before { .icon-video-outlined::before {
content: "\f249"; content: "\f249";
} }
.icon-view-once::before { .icon-video-stop::before {
content: "\f24a"; content: "\f24a";
} }
.icon-voice-chat::before { .icon-view-once::before {
content: "\f24b"; content: "\f24b";
} }
.icon-volume-1::before { .icon-voice-chat::before {
content: "\f24c"; content: "\f24c";
} }
.icon-volume-2::before { .icon-volume-1::before {
content: "\f24d"; content: "\f24d";
} }
.icon-volume-3::before { .icon-volume-2::before {
content: "\f24e"; content: "\f24e";
} }
.icon-warning::before { .icon-volume-3::before {
content: "\f24f"; content: "\f24f";
} }
.icon-web::before { .icon-warning::before {
content: "\f250"; content: "\f250";
} }
.icon-webapp::before { .icon-web::before {
content: "\f251"; content: "\f251";
} }
.icon-word-wrap::before { .icon-webapp::before {
content: "\f252"; content: "\f252";
} }
.icon-zoom-in::before { .icon-word-wrap::before {
content: "\f253"; content: "\f253";
} }
.icon-zoom-out::before { .icon-zoom-in::before {
content: "\f254"; content: "\f254";
} }
.icon-zoom-out::before {
content: "\f255";
}

View File

@ -249,111 +249,112 @@ $icons-map: (
"redo": "\f1e7", "redo": "\f1e7",
"refund": "\f1e8", "refund": "\f1e8",
"reload": "\f1e9", "reload": "\f1e9",
"remove": "\f1ea", "reload-arrows": "\f1ea",
"remove-quote": "\f1eb", "remove": "\f1eb",
"reopen-topic": "\f1ec", "remove-quote": "\f1ec",
"reorder-tabs": "\f1ed", "reopen-topic": "\f1ed",
"replace": "\f1ee", "reorder-tabs": "\f1ee",
"replace-round": "\f1ef", "replace": "\f1ef",
"replies": "\f1f0", "replace-round": "\f1f0",
"reply": "\f1f1", "replies": "\f1f1",
"reply-filled": "\f1f2", "reply": "\f1f2",
"revenue-split": "\f1f3", "reply-filled": "\f1f3",
"revote": "\f1f4", "revenue-split": "\f1f4",
"rotate": "\f1f5", "revote": "\f1f5",
"save-story": "\f1f6", "rotate": "\f1f6",
"saved-messages": "\f1f7", "save-story": "\f1f7",
"schedule": "\f1f8", "saved-messages": "\f1f8",
"scheduled": "\f1f9", "schedule": "\f1f9",
"sd-photo": "\f1fa", "scheduled": "\f1fa",
"search": "\f1fb", "sd-photo": "\f1fb",
"select": "\f1fc", "search": "\f1fc",
"select-filled": "\f1fd", "select": "\f1fd",
"sell": "\f1fe", "select-filled": "\f1fe",
"sell-outline": "\f1ff", "sell": "\f1ff",
"send": "\f200", "sell-outline": "\f200",
"send-outline": "\f201", "send": "\f201",
"settings": "\f202", "send-outline": "\f202",
"settings-filled": "\f203", "settings": "\f203",
"share-filled": "\f204", "settings-filled": "\f204",
"share-screen": "\f205", "share-filled": "\f205",
"share-screen-outlined": "\f206", "share-screen": "\f206",
"share-screen-stop": "\f207", "share-screen-outlined": "\f207",
"show-message": "\f208", "share-screen-stop": "\f208",
"sidebar": "\f209", "show-message": "\f209",
"skip-next": "\f20a", "sidebar": "\f20a",
"skip-previous": "\f20b", "skip-next": "\f20b",
"smallscreen": "\f20c", "skip-previous": "\f20c",
"smile": "\f20d", "smallscreen": "\f20d",
"sort": "\f20e", "smile": "\f20e",
"sort-by-date": "\f20f", "sort": "\f20f",
"sort-by-number": "\f210", "sort-by-date": "\f210",
"sort-by-price": "\f211", "sort-by-number": "\f211",
"speaker": "\f212", "sort-by-price": "\f212",
"speaker-muted-story": "\f213", "speaker": "\f213",
"speaker-outline": "\f214", "speaker-muted-story": "\f214",
"speaker-story": "\f215", "speaker-outline": "\f215",
"spoiler": "\f216", "speaker-story": "\f216",
"spoiler-disable": "\f217", "spoiler": "\f217",
"sport": "\f218", "spoiler-disable": "\f218",
"star": "\f219", "sport": "\f219",
"stars-lock": "\f21a", "star": "\f21a",
"stars-refund": "\f21b", "stars-lock": "\f21b",
"stats": "\f21c", "stars-refund": "\f21c",
"stealth-future": "\f21d", "stats": "\f21d",
"stealth-past": "\f21e", "stealth-future": "\f21e",
"stickers": "\f21f", "stealth-past": "\f21f",
"stop": "\f220", "stickers": "\f220",
"stop-raising-hand": "\f221", "stop": "\f221",
"story-caption": "\f222", "stop-raising-hand": "\f222",
"story-expired": "\f223", "story-caption": "\f223",
"story-priority": "\f224", "story-expired": "\f224",
"story-reply": "\f225", "story-priority": "\f225",
"strikethrough": "\f226", "story-reply": "\f226",
"tag": "\f227", "strikethrough": "\f227",
"tag-add": "\f228", "tag": "\f228",
"tag-crossed": "\f229", "tag-add": "\f229",
"tag-filter": "\f22a", "tag-crossed": "\f22a",
"tag-name": "\f22b", "tag-filter": "\f22b",
"timer": "\f22c", "tag-name": "\f22c",
"timer-filled": "\f22d", "timer": "\f22d",
"toncoin": "\f22e", "timer-filled": "\f22e",
"tone": "\f22f", "toncoin": "\f22f",
"tools": "\f230", "tone": "\f230",
"topic-new": "\f231", "tools": "\f231",
"trade": "\f232", "topic-new": "\f232",
"transcribe": "\f233", "trade": "\f233",
"truck": "\f234", "transcribe": "\f234",
"unarchive": "\f235", "truck": "\f235",
"underlined": "\f236", "unarchive": "\f236",
"understood": "\f237", "underlined": "\f237",
"undo": "\f238", "understood": "\f238",
"unique-profile": "\f239", "undo": "\f239",
"unlist": "\f23a", "unique-profile": "\f23a",
"unlist-outline": "\f23b", "unlist": "\f23b",
"unlock": "\f23c", "unlist-outline": "\f23c",
"unlock-badge": "\f23d", "unlock": "\f23d",
"unmute": "\f23e", "unlock-badge": "\f23e",
"unpin": "\f23f", "unmute": "\f23f",
"unread": "\f240", "unpin": "\f240",
"up": "\f241", "unread": "\f241",
"user": "\f242", "up": "\f242",
"user-filled": "\f243", "user": "\f243",
"user-online": "\f244", "user-filled": "\f244",
"user-stars": "\f245", "user-online": "\f245",
"user-tag": "\f246", "user-stars": "\f246",
"video": "\f247", "user-tag": "\f247",
"video-outlined": "\f248", "video": "\f248",
"video-stop": "\f249", "video-outlined": "\f249",
"view-once": "\f24a", "video-stop": "\f24a",
"voice-chat": "\f24b", "view-once": "\f24b",
"volume-1": "\f24c", "voice-chat": "\f24c",
"volume-2": "\f24d", "volume-1": "\f24d",
"volume-3": "\f24e", "volume-2": "\f24e",
"warning": "\f24f", "volume-3": "\f24f",
"web": "\f250", "warning": "\f250",
"webapp": "\f251", "web": "\f251",
"word-wrap": "\f252", "webapp": "\f252",
"zoom-in": "\f253", "word-wrap": "\f253",
"zoom-out": "\f254", "zoom-in": "\f254",
"zoom-out": "\f255",
); );

Binary file not shown.

Binary file not shown.

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>icons Preview</title> <title>icons Preview</title>
<link rel="stylesheet" href="../icons.css?e9a7be346eaac71f831beea8710b79fd"> <link rel="stylesheet" href="../icons.css?2ae8509ab058d7ef62dd8b67cc2b2b75">
<style> <style>
:root { :root {
color-scheme: light; color-scheme: light;
@ -93,7 +93,7 @@
<main class="page"> <main class="page">
<header class="header"> <header class="header">
<h1 class="title">icons Preview</h1> <h1 class="title">icons Preview</h1>
<p class="subtitle">340 icons</p> <p class="subtitle">341 icons</p>
</header> </header>
<section class="grid"> <section class="grid">
<article class="card"> <article class="card">
@ -1261,540 +1261,545 @@
<div class="name">reload</div> <div class="name">reload</div>
<div class="code">\f1e9</div> <div class="code">\f1e9</div>
</article> </article>
<article class="card">
<i class="icon icon-reload-arrows"></i>
<div class="name">reload-arrows</div>
<div class="code">\f1ea</div>
</article>
<article class="card"> <article class="card">
<i class="icon icon-remove"></i> <i class="icon icon-remove"></i>
<div class="name">remove</div> <div class="name">remove</div>
<div class="code">\f1ea</div> <div class="code">\f1eb</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-remove-quote"></i> <i class="icon icon-remove-quote"></i>
<div class="name">remove-quote</div> <div class="name">remove-quote</div>
<div class="code">\f1eb</div> <div class="code">\f1ec</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-reopen-topic"></i> <i class="icon icon-reopen-topic"></i>
<div class="name">reopen-topic</div> <div class="name">reopen-topic</div>
<div class="code">\f1ec</div> <div class="code">\f1ed</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-reorder-tabs"></i> <i class="icon icon-reorder-tabs"></i>
<div class="name">reorder-tabs</div> <div class="name">reorder-tabs</div>
<div class="code">\f1ed</div> <div class="code">\f1ee</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-replace"></i> <i class="icon icon-replace"></i>
<div class="name">replace</div> <div class="name">replace</div>
<div class="code">\f1ee</div> <div class="code">\f1ef</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-replace-round"></i> <i class="icon icon-replace-round"></i>
<div class="name">replace-round</div> <div class="name">replace-round</div>
<div class="code">\f1ef</div> <div class="code">\f1f0</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-replies"></i> <i class="icon icon-replies"></i>
<div class="name">replies</div> <div class="name">replies</div>
<div class="code">\f1f0</div> <div class="code">\f1f1</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-reply"></i> <i class="icon icon-reply"></i>
<div class="name">reply</div> <div class="name">reply</div>
<div class="code">\f1f1</div> <div class="code">\f1f2</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-reply-filled"></i> <i class="icon icon-reply-filled"></i>
<div class="name">reply-filled</div> <div class="name">reply-filled</div>
<div class="code">\f1f2</div> <div class="code">\f1f3</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-revenue-split"></i> <i class="icon icon-revenue-split"></i>
<div class="name">revenue-split</div> <div class="name">revenue-split</div>
<div class="code">\f1f3</div> <div class="code">\f1f4</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-revote"></i> <i class="icon icon-revote"></i>
<div class="name">revote</div> <div class="name">revote</div>
<div class="code">\f1f4</div> <div class="code">\f1f5</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-rotate"></i> <i class="icon icon-rotate"></i>
<div class="name">rotate</div> <div class="name">rotate</div>
<div class="code">\f1f5</div> <div class="code">\f1f6</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-save-story"></i> <i class="icon icon-save-story"></i>
<div class="name">save-story</div> <div class="name">save-story</div>
<div class="code">\f1f6</div> <div class="code">\f1f7</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-saved-messages"></i> <i class="icon icon-saved-messages"></i>
<div class="name">saved-messages</div> <div class="name">saved-messages</div>
<div class="code">\f1f7</div> <div class="code">\f1f8</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-schedule"></i> <i class="icon icon-schedule"></i>
<div class="name">schedule</div> <div class="name">schedule</div>
<div class="code">\f1f8</div> <div class="code">\f1f9</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-scheduled"></i> <i class="icon icon-scheduled"></i>
<div class="name">scheduled</div> <div class="name">scheduled</div>
<div class="code">\f1f9</div> <div class="code">\f1fa</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sd-photo"></i> <i class="icon icon-sd-photo"></i>
<div class="name">sd-photo</div> <div class="name">sd-photo</div>
<div class="code">\f1fa</div> <div class="code">\f1fb</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-search"></i> <i class="icon icon-search"></i>
<div class="name">search</div> <div class="name">search</div>
<div class="code">\f1fb</div> <div class="code">\f1fc</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-select"></i> <i class="icon icon-select"></i>
<div class="name">select</div> <div class="name">select</div>
<div class="code">\f1fc</div> <div class="code">\f1fd</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-select-filled"></i> <i class="icon icon-select-filled"></i>
<div class="name">select-filled</div> <div class="name">select-filled</div>
<div class="code">\f1fd</div> <div class="code">\f1fe</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sell"></i> <i class="icon icon-sell"></i>
<div class="name">sell</div> <div class="name">sell</div>
<div class="code">\f1fe</div> <div class="code">\f1ff</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sell-outline"></i> <i class="icon icon-sell-outline"></i>
<div class="name">sell-outline</div> <div class="name">sell-outline</div>
<div class="code">\f1ff</div> <div class="code">\f200</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-send"></i> <i class="icon icon-send"></i>
<div class="name">send</div> <div class="name">send</div>
<div class="code">\f200</div> <div class="code">\f201</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-send-outline"></i> <i class="icon icon-send-outline"></i>
<div class="name">send-outline</div> <div class="name">send-outline</div>
<div class="code">\f201</div> <div class="code">\f202</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-settings"></i> <i class="icon icon-settings"></i>
<div class="name">settings</div> <div class="name">settings</div>
<div class="code">\f202</div> <div class="code">\f203</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-settings-filled"></i> <i class="icon icon-settings-filled"></i>
<div class="name">settings-filled</div> <div class="name">settings-filled</div>
<div class="code">\f203</div> <div class="code">\f204</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-share-filled"></i> <i class="icon icon-share-filled"></i>
<div class="name">share-filled</div> <div class="name">share-filled</div>
<div class="code">\f204</div> <div class="code">\f205</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-share-screen"></i> <i class="icon icon-share-screen"></i>
<div class="name">share-screen</div> <div class="name">share-screen</div>
<div class="code">\f205</div> <div class="code">\f206</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-share-screen-outlined"></i> <i class="icon icon-share-screen-outlined"></i>
<div class="name">share-screen-outlined</div> <div class="name">share-screen-outlined</div>
<div class="code">\f206</div> <div class="code">\f207</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-share-screen-stop"></i> <i class="icon icon-share-screen-stop"></i>
<div class="name">share-screen-stop</div> <div class="name">share-screen-stop</div>
<div class="code">\f207</div> <div class="code">\f208</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-show-message"></i> <i class="icon icon-show-message"></i>
<div class="name">show-message</div> <div class="name">show-message</div>
<div class="code">\f208</div> <div class="code">\f209</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sidebar"></i> <i class="icon icon-sidebar"></i>
<div class="name">sidebar</div> <div class="name">sidebar</div>
<div class="code">\f209</div> <div class="code">\f20a</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-skip-next"></i> <i class="icon icon-skip-next"></i>
<div class="name">skip-next</div> <div class="name">skip-next</div>
<div class="code">\f20a</div> <div class="code">\f20b</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-skip-previous"></i> <i class="icon icon-skip-previous"></i>
<div class="name">skip-previous</div> <div class="name">skip-previous</div>
<div class="code">\f20b</div> <div class="code">\f20c</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-smallscreen"></i> <i class="icon icon-smallscreen"></i>
<div class="name">smallscreen</div> <div class="name">smallscreen</div>
<div class="code">\f20c</div> <div class="code">\f20d</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-smile"></i> <i class="icon icon-smile"></i>
<div class="name">smile</div> <div class="name">smile</div>
<div class="code">\f20d</div> <div class="code">\f20e</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sort"></i> <i class="icon icon-sort"></i>
<div class="name">sort</div> <div class="name">sort</div>
<div class="code">\f20e</div> <div class="code">\f20f</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sort-by-date"></i> <i class="icon icon-sort-by-date"></i>
<div class="name">sort-by-date</div> <div class="name">sort-by-date</div>
<div class="code">\f20f</div> <div class="code">\f210</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sort-by-number"></i> <i class="icon icon-sort-by-number"></i>
<div class="name">sort-by-number</div> <div class="name">sort-by-number</div>
<div class="code">\f210</div> <div class="code">\f211</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sort-by-price"></i> <i class="icon icon-sort-by-price"></i>
<div class="name">sort-by-price</div> <div class="name">sort-by-price</div>
<div class="code">\f211</div> <div class="code">\f212</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-speaker"></i> <i class="icon icon-speaker"></i>
<div class="name">speaker</div> <div class="name">speaker</div>
<div class="code">\f212</div> <div class="code">\f213</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-speaker-muted-story"></i> <i class="icon icon-speaker-muted-story"></i>
<div class="name">speaker-muted-story</div> <div class="name">speaker-muted-story</div>
<div class="code">\f213</div> <div class="code">\f214</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-speaker-outline"></i> <i class="icon icon-speaker-outline"></i>
<div class="name">speaker-outline</div> <div class="name">speaker-outline</div>
<div class="code">\f214</div> <div class="code">\f215</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-speaker-story"></i> <i class="icon icon-speaker-story"></i>
<div class="name">speaker-story</div> <div class="name">speaker-story</div>
<div class="code">\f215</div> <div class="code">\f216</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-spoiler"></i> <i class="icon icon-spoiler"></i>
<div class="name">spoiler</div> <div class="name">spoiler</div>
<div class="code">\f216</div> <div class="code">\f217</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-spoiler-disable"></i> <i class="icon icon-spoiler-disable"></i>
<div class="name">spoiler-disable</div> <div class="name">spoiler-disable</div>
<div class="code">\f217</div> <div class="code">\f218</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-sport"></i> <i class="icon icon-sport"></i>
<div class="name">sport</div> <div class="name">sport</div>
<div class="code">\f218</div> <div class="code">\f219</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-star"></i> <i class="icon icon-star"></i>
<div class="name">star</div> <div class="name">star</div>
<div class="code">\f219</div> <div class="code">\f21a</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stars-lock"></i> <i class="icon icon-stars-lock"></i>
<div class="name">stars-lock</div> <div class="name">stars-lock</div>
<div class="code">\f21a</div> <div class="code">\f21b</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stars-refund"></i> <i class="icon icon-stars-refund"></i>
<div class="name">stars-refund</div> <div class="name">stars-refund</div>
<div class="code">\f21b</div> <div class="code">\f21c</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stats"></i> <i class="icon icon-stats"></i>
<div class="name">stats</div> <div class="name">stats</div>
<div class="code">\f21c</div> <div class="code">\f21d</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stealth-future"></i> <i class="icon icon-stealth-future"></i>
<div class="name">stealth-future</div> <div class="name">stealth-future</div>
<div class="code">\f21d</div> <div class="code">\f21e</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stealth-past"></i> <i class="icon icon-stealth-past"></i>
<div class="name">stealth-past</div> <div class="name">stealth-past</div>
<div class="code">\f21e</div> <div class="code">\f21f</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stickers"></i> <i class="icon icon-stickers"></i>
<div class="name">stickers</div> <div class="name">stickers</div>
<div class="code">\f21f</div> <div class="code">\f220</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stop"></i> <i class="icon icon-stop"></i>
<div class="name">stop</div> <div class="name">stop</div>
<div class="code">\f220</div> <div class="code">\f221</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-stop-raising-hand"></i> <i class="icon icon-stop-raising-hand"></i>
<div class="name">stop-raising-hand</div> <div class="name">stop-raising-hand</div>
<div class="code">\f221</div> <div class="code">\f222</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-story-caption"></i> <i class="icon icon-story-caption"></i>
<div class="name">story-caption</div> <div class="name">story-caption</div>
<div class="code">\f222</div> <div class="code">\f223</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-story-expired"></i> <i class="icon icon-story-expired"></i>
<div class="name">story-expired</div> <div class="name">story-expired</div>
<div class="code">\f223</div> <div class="code">\f224</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-story-priority"></i> <i class="icon icon-story-priority"></i>
<div class="name">story-priority</div> <div class="name">story-priority</div>
<div class="code">\f224</div> <div class="code">\f225</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-story-reply"></i> <i class="icon icon-story-reply"></i>
<div class="name">story-reply</div> <div class="name">story-reply</div>
<div class="code">\f225</div> <div class="code">\f226</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-strikethrough"></i> <i class="icon icon-strikethrough"></i>
<div class="name">strikethrough</div> <div class="name">strikethrough</div>
<div class="code">\f226</div> <div class="code">\f227</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tag"></i> <i class="icon icon-tag"></i>
<div class="name">tag</div> <div class="name">tag</div>
<div class="code">\f227</div> <div class="code">\f228</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tag-add"></i> <i class="icon icon-tag-add"></i>
<div class="name">tag-add</div> <div class="name">tag-add</div>
<div class="code">\f228</div> <div class="code">\f229</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tag-crossed"></i> <i class="icon icon-tag-crossed"></i>
<div class="name">tag-crossed</div> <div class="name">tag-crossed</div>
<div class="code">\f229</div> <div class="code">\f22a</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tag-filter"></i> <i class="icon icon-tag-filter"></i>
<div class="name">tag-filter</div> <div class="name">tag-filter</div>
<div class="code">\f22a</div> <div class="code">\f22b</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tag-name"></i> <i class="icon icon-tag-name"></i>
<div class="name">tag-name</div> <div class="name">tag-name</div>
<div class="code">\f22b</div> <div class="code">\f22c</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-timer"></i> <i class="icon icon-timer"></i>
<div class="name">timer</div> <div class="name">timer</div>
<div class="code">\f22c</div> <div class="code">\f22d</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-timer-filled"></i> <i class="icon icon-timer-filled"></i>
<div class="name">timer-filled</div> <div class="name">timer-filled</div>
<div class="code">\f22d</div> <div class="code">\f22e</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-toncoin"></i> <i class="icon icon-toncoin"></i>
<div class="name">toncoin</div> <div class="name">toncoin</div>
<div class="code">\f22e</div> <div class="code">\f22f</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tone"></i> <i class="icon icon-tone"></i>
<div class="name">tone</div> <div class="name">tone</div>
<div class="code">\f22f</div> <div class="code">\f230</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-tools"></i> <i class="icon icon-tools"></i>
<div class="name">tools</div> <div class="name">tools</div>
<div class="code">\f230</div> <div class="code">\f231</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-topic-new"></i> <i class="icon icon-topic-new"></i>
<div class="name">topic-new</div> <div class="name">topic-new</div>
<div class="code">\f231</div> <div class="code">\f232</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-trade"></i> <i class="icon icon-trade"></i>
<div class="name">trade</div> <div class="name">trade</div>
<div class="code">\f232</div> <div class="code">\f233</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-transcribe"></i> <i class="icon icon-transcribe"></i>
<div class="name">transcribe</div> <div class="name">transcribe</div>
<div class="code">\f233</div> <div class="code">\f234</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-truck"></i> <i class="icon icon-truck"></i>
<div class="name">truck</div> <div class="name">truck</div>
<div class="code">\f234</div> <div class="code">\f235</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unarchive"></i> <i class="icon icon-unarchive"></i>
<div class="name">unarchive</div> <div class="name">unarchive</div>
<div class="code">\f235</div> <div class="code">\f236</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-underlined"></i> <i class="icon icon-underlined"></i>
<div class="name">underlined</div> <div class="name">underlined</div>
<div class="code">\f236</div> <div class="code">\f237</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-understood"></i> <i class="icon icon-understood"></i>
<div class="name">understood</div> <div class="name">understood</div>
<div class="code">\f237</div> <div class="code">\f238</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-undo"></i> <i class="icon icon-undo"></i>
<div class="name">undo</div> <div class="name">undo</div>
<div class="code">\f238</div> <div class="code">\f239</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unique-profile"></i> <i class="icon icon-unique-profile"></i>
<div class="name">unique-profile</div> <div class="name">unique-profile</div>
<div class="code">\f239</div> <div class="code">\f23a</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unlist"></i> <i class="icon icon-unlist"></i>
<div class="name">unlist</div> <div class="name">unlist</div>
<div class="code">\f23a</div> <div class="code">\f23b</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unlist-outline"></i> <i class="icon icon-unlist-outline"></i>
<div class="name">unlist-outline</div> <div class="name">unlist-outline</div>
<div class="code">\f23b</div> <div class="code">\f23c</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unlock"></i> <i class="icon icon-unlock"></i>
<div class="name">unlock</div> <div class="name">unlock</div>
<div class="code">\f23c</div> <div class="code">\f23d</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unlock-badge"></i> <i class="icon icon-unlock-badge"></i>
<div class="name">unlock-badge</div> <div class="name">unlock-badge</div>
<div class="code">\f23d</div> <div class="code">\f23e</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unmute"></i> <i class="icon icon-unmute"></i>
<div class="name">unmute</div> <div class="name">unmute</div>
<div class="code">\f23e</div> <div class="code">\f23f</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unpin"></i> <i class="icon icon-unpin"></i>
<div class="name">unpin</div> <div class="name">unpin</div>
<div class="code">\f23f</div> <div class="code">\f240</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-unread"></i> <i class="icon icon-unread"></i>
<div class="name">unread</div> <div class="name">unread</div>
<div class="code">\f240</div> <div class="code">\f241</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-up"></i> <i class="icon icon-up"></i>
<div class="name">up</div> <div class="name">up</div>
<div class="code">\f241</div> <div class="code">\f242</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-user"></i> <i class="icon icon-user"></i>
<div class="name">user</div> <div class="name">user</div>
<div class="code">\f242</div> <div class="code">\f243</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-user-filled"></i> <i class="icon icon-user-filled"></i>
<div class="name">user-filled</div> <div class="name">user-filled</div>
<div class="code">\f243</div> <div class="code">\f244</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-user-online"></i> <i class="icon icon-user-online"></i>
<div class="name">user-online</div> <div class="name">user-online</div>
<div class="code">\f244</div> <div class="code">\f245</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-user-stars"></i> <i class="icon icon-user-stars"></i>
<div class="name">user-stars</div> <div class="name">user-stars</div>
<div class="code">\f245</div> <div class="code">\f246</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-user-tag"></i> <i class="icon icon-user-tag"></i>
<div class="name">user-tag</div> <div class="name">user-tag</div>
<div class="code">\f246</div> <div class="code">\f247</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-video"></i> <i class="icon icon-video"></i>
<div class="name">video</div> <div class="name">video</div>
<div class="code">\f247</div> <div class="code">\f248</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-video-outlined"></i> <i class="icon icon-video-outlined"></i>
<div class="name">video-outlined</div> <div class="name">video-outlined</div>
<div class="code">\f248</div> <div class="code">\f249</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-video-stop"></i> <i class="icon icon-video-stop"></i>
<div class="name">video-stop</div> <div class="name">video-stop</div>
<div class="code">\f249</div> <div class="code">\f24a</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-view-once"></i> <i class="icon icon-view-once"></i>
<div class="name">view-once</div> <div class="name">view-once</div>
<div class="code">\f24a</div> <div class="code">\f24b</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-voice-chat"></i> <i class="icon icon-voice-chat"></i>
<div class="name">voice-chat</div> <div class="name">voice-chat</div>
<div class="code">\f24b</div> <div class="code">\f24c</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-volume-1"></i> <i class="icon icon-volume-1"></i>
<div class="name">volume-1</div> <div class="name">volume-1</div>
<div class="code">\f24c</div> <div class="code">\f24d</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-volume-2"></i> <i class="icon icon-volume-2"></i>
<div class="name">volume-2</div> <div class="name">volume-2</div>
<div class="code">\f24d</div> <div class="code">\f24e</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-volume-3"></i> <i class="icon icon-volume-3"></i>
<div class="name">volume-3</div> <div class="name">volume-3</div>
<div class="code">\f24e</div> <div class="code">\f24f</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-warning"></i> <i class="icon icon-warning"></i>
<div class="name">warning</div> <div class="name">warning</div>
<div class="code">\f24f</div> <div class="code">\f250</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-web"></i> <i class="icon icon-web"></i>
<div class="name">web</div> <div class="name">web</div>
<div class="code">\f250</div> <div class="code">\f251</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-webapp"></i> <i class="icon icon-webapp"></i>
<div class="name">webapp</div> <div class="name">webapp</div>
<div class="code">\f251</div> <div class="code">\f252</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-word-wrap"></i> <i class="icon icon-word-wrap"></i>
<div class="name">word-wrap</div> <div class="name">word-wrap</div>
<div class="code">\f252</div> <div class="code">\f253</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-zoom-in"></i> <i class="icon icon-zoom-in"></i>
<div class="name">zoom-in</div> <div class="name">zoom-in</div>
<div class="code">\f253</div> <div class="code">\f254</div>
</article> </article>
<article class="card"> <article class="card">
<i class="icon icon-zoom-out"></i> <i class="icon icon-zoom-out"></i>
<div class="name">zoom-out</div> <div class="name">zoom-out</div>
<div class="code">\f254</div> <div class="code">\f255</div>
</article> </article>
</section> </section>
</main> </main>

View File

@ -232,6 +232,7 @@ export type FontIconName =
| 'redo' | 'redo'
| 'refund' | 'refund'
| 'reload' | 'reload'
| 'reload-arrows'
| 'remove' | 'remove'
| 'remove-quote' | 'remove-quote'
| 'reopen-topic' | 'reopen-topic'

View File

@ -1533,6 +1533,7 @@ export interface LangPair {
'ViewButtonStickerset': undefined; 'ViewButtonStickerset': undefined;
'ViewButtonEmojiset': undefined; 'ViewButtonEmojiset': undefined;
'ViewButtonGiftUnique': undefined; 'ViewButtonGiftUnique': undefined;
'ViewButtonAiStyle': undefined;
'AuthContinueOnThisLanguage': undefined; 'AuthContinueOnThisLanguage': undefined;
'Share': undefined; 'Share': undefined;
'GiftSortByDate': undefined; 'GiftSortByDate': undefined;
@ -2108,6 +2109,27 @@ export interface LangPair {
'AiMessageEditorApply': undefined; 'AiMessageEditorApply': undefined;
'AiMessageEditorEmojify': undefined; 'AiMessageEditorEmojify': undefined;
'AiMessageEditorTranslation': undefined; 'AiMessageEditorTranslation': undefined;
'AiToneEditorNewStyle': undefined;
'AiToneEditorTitle': undefined;
'AiToneEditorNamePlaceholder': undefined;
'AiToneEditorPromptPlaceholder': undefined;
'AiToneEditorDisplayAuthor': undefined;
'AiToneEditorSelectEmoji': undefined;
'AiToneCreatedHint': undefined;
'AiToneEditStyle': undefined;
'AiToneShareStyle': undefined;
'AiToneDeleteStyle': undefined;
'AiToneDeleteStyleConfirmOwn': undefined;
'AiToneDeleteStyleConfirm': undefined;
'AiToneEditorEditTitle': undefined;
'AiTonePreviewSubtitle': undefined;
'AiTonePreviewBefore': undefined;
'AiTonePreviewAnotherExample': undefined;
'AiTonePreviewAfter': undefined;
'AiTonePreviewAddStyle': undefined;
'AiTonePreviewRemoveStyle': undefined;
'AiTonePreviewStyleAdded': undefined;
'AiToneLimitReached': undefined;
'TextShowMore': undefined; 'TextShowMore': undefined;
'TextShowLess': undefined; 'TextShowLess': undefined;
'AiMessageEditorFrom': undefined; 'AiMessageEditorFrom': undefined;
@ -3741,6 +3763,22 @@ export interface LangPairWithVariables<V = LangVariable> {
'AiMessageEditorDailyLimitReached': { 'AiMessageEditorDailyLimitReached': {
'link': V; 'link': V;
}; };
'AiToneCreated': {
'title': V;
};
'AiToneLimitReachedPremium': {
'limit': V;
};
'AiTonePreviewUsedBy': {
'count': V;
};
'AiTonePreviewCreatedBy': {
'author': V;
};
'AiTonePreviewUsedByCreatedBy': {
'usedBy': V;
'createdBy': V;
};
'UnofficialSecurityRisk': { 'UnofficialSecurityRisk': {
'peer': V; 'peer': V;
}; };

View File

@ -8,9 +8,9 @@ import { toChannelId } from './entities/ids';
import { isUsernameValid } from './entities/username'; import { isUsernameValid } from './entities/username';
export type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' | export type DeepLinkMethod = 'resolve' | 'login' | 'passport' | 'settings' | 'join' | 'addstickers' | 'addemoji' |
'setlanguage' | 'addtheme' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' | 'msg' | 'msg_url' | 'setlanguage' | 'addtheme' | 'addstyle' | 'confirmphone' | 'socks' | 'proxy' | 'privatepost' | 'bg' | 'share' |
'invoice' | 'addlist' | 'boost' | 'giftcode' | 'message' | 'premium_offer' | 'premium_multigift' | 'stars_topup' 'msg' | 'msg_url' | 'invoice' | 'addlist' | 'boost' | 'giftcode' | 'message' | 'premium_offer' |
| 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium' | 'oauth'; 'premium_multigift' | 'stars_topup' | 'nft' | 'stars' | 'ton' | 'stargift_auction' | 'premium' | 'oauth';
interface PublicMessageLink { interface PublicMessageLink {
type: 'publicMessageLink'; type: 'publicMessageLink';

View File

@ -222,6 +222,12 @@ export const processDeepLink = (url: string, linkContext?: LinkContext): boolean
}); });
break; break;
} }
case 'addstyle': {
const { set } = params;
if (!set) return false;
actions.openAiTonePreview({ slug: set });
break;
}
case 'share': case 'share':
case 'msg': case 'msg':
case 'msg_url': { case 'msg_url': {