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'
| 'recommended_channels_limit'
| 'saved_dialogs_pinned_limit'
| 'reactions_user_max';
| 'reactions_user_max'
| 'aicompose_tone_saved_limit';
type LimitKey = `${Limit}_${LimitType}`;
type LimitsConfig = Record<LimitKey, number>;
@ -135,8 +136,6 @@ export interface GramJsAppConfig extends LimitsConfig {
aicompose_tone_examples_num?: number;
aicompose_tone_title_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) {
@ -217,6 +216,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
savedDialogsPinned: getLimit(appConfig, 'saved_dialogs_pinned_limit', 'savedDialogsPinned'),
maxReactions: getLimit(appConfig, 'reactions_user_max', 'maxReactions'),
moreAccounts: DEFAULT_LIMITS.moreAccounts,
aiComposeToneSaved: getLimit(appConfig, 'aicompose_tone_saved_limit', 'aiComposeToneSaved'),
},
contactNoteLimit: appConfig.contact_note_length_limit,
hash,
@ -284,8 +284,6 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp
aiComposeToneExamplesNum: appConfig.aicompose_tone_examples_num,
aiComposeToneTitleLengthMax: appConfig.aicompose_tone_title_length_max,
aiComposeTonePromptLengthMax: appConfig.aicompose_tone_prompt_length_max,
aiComposeToneSavedLimitDefault: appConfig.aicompose_tone_saved_limit_default,
aiComposeToneSavedLimitPremium: appConfig.aicompose_tone_saved_limit_premium,
};
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 {
mediaType: 'webpage',
webpageType: 'full',
@ -1026,6 +1030,7 @@ export function buildWebPage(webPage: GramJs.TypeWebPage): ApiWebPage | undefine
gift,
auction,
stickers,
aiComposeToneEmojiId: attributeAiTone?.emojiId.toString(),
};
}

View File

@ -64,7 +64,9 @@ import {
buildApiSponsoredMessageReportResult,
buildThreadReadState,
} from '../apiBuilders/chats';
import { buildApiAiComposeTone, buildApiComposedMessageWithAI, buildApiFormattedText } from '../apiBuilders/common';
import {
buildApiAiComposeTone, buildApiAiComposeToneExample, buildApiComposedMessageWithAI, buildApiFormattedText,
} from '../apiBuilders/common';
import { buildApiTopicWithState } from '../apiBuilders/forums';
import {
buildMessageMediaContent, buildMessagePollFromMedia, buildMessageTextContent,
@ -2853,3 +2855,110 @@ export async function fetchAiComposeTones({
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;
auction?: ApiWebPageAuctionData;
stickers?: ApiWebPageStickerData;
aiComposeToneEmojiId?: string;
hasLargeMedia?: boolean;
}

View File

@ -337,8 +337,6 @@ export interface ApiAppConfig {
aiComposeToneExamplesNum?: number;
aiComposeToneTitleLengthMax?: number;
aiComposeTonePromptLengthMax?: number;
aiComposeToneSavedLimitDefault?: number;
aiComposeToneSavedLimitPremium?: number;
}
export interface ApiConfig {
@ -464,15 +462,17 @@ export type ApiLimitType =
| 'recommendedChannels'
| 'savedDialogsPinned'
| 'maxReactions'
| 'moreAccounts';
| 'moreAccounts'
| 'aiComposeToneSaved';
export type ApiLimitTypeWithModal = Exclude<ApiLimitType, (
'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs' | 'recommendedChannels' | 'moreAccounts'
| 'maxReactions'
| 'maxReactions' | 'aiComposeToneSaved'
)>;
export type ApiLimitTypeForPromo = Exclude<ApiLimitType,
'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined' | 'savedDialogsPinned' | 'maxReactions'
'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined' | 'savedDialogsPinned' | 'maxReactions'
| 'aiComposeToneSaved'
>;
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";
"ViewButtonEmojiset" = "VIEW EMOJI";
"ViewButtonGiftUnique" = "VIEW COLLECTIBLE";
"ViewButtonAiStyle" = "VIEW STYLE";
"AuthContinueOnThisLanguage" = "Continue in English";
"Share" = "Share";
"GiftSortByDate" = "Sort by Date";
@ -2889,6 +2890,32 @@
"AiMessageEditorApply" = "Apply";
"AiMessageEditorEmojify" = "emojify";
"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";
"TextShowLess" = "less";
"AiMessageEditorFrom" = "From";

View File

@ -73,6 +73,10 @@ export { default as ReactionPicker } from '../components/middle/message/reaction
export { default as 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 PollModal } from '../components/modals/poll/PollModal';

View File

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

View File

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

View File

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

View File

@ -43,6 +43,10 @@ export type ModalProps = {
height?: ModalHeight;
noBackdrop?: boolean;
noLightDismiss?: boolean;
noScrollable?: boolean;
noContentInlinePadding?: boolean;
keepMounted?: boolean;
stickyFooter?: TeactNode;
ariaLabel?: string;
noContainment?: boolean;
onClose: NoneToVoidFunction;
@ -114,10 +118,15 @@ const Modal = ({
height = 'regular',
noBackdrop,
noLightDismiss,
noScrollable,
noContentInlinePadding,
keepMounted,
stickyFooter,
ariaLabel,
noContainment,
onClose,
}: ModalProps) => {
const [hasEverOpened, setHasEverOpened] = useState(Boolean(isOpen));
const [shouldRender, setShouldRender] = useState(Boolean(isOpen));
const [isClosing, setIsClosing] = useState(false);
const [hasTitle, setHasTitle] = useState(false);
@ -134,11 +143,14 @@ const Modal = ({
const frozenProps = useFrozenProps({
header,
children,
stickyFooter,
dialogClassName,
contentClassName,
width,
height,
noBackdrop,
noScrollable,
noContentInlinePadding,
ariaLabel,
noContainment,
}, !isOpen);
@ -193,6 +205,12 @@ const Modal = ({
titleId,
]);
useEffect(() => {
if (isOpen && !hasEverOpened) {
setHasEverOpened(true);
}
}, [hasEverOpened, isOpen]);
useEffect(() => {
if (isOpen) {
cleanupCloseAnimation();
@ -322,7 +340,7 @@ const Modal = ({
handleRequestClose();
});
if (!shouldRender) {
if (!shouldRender && !(keepMounted && hasEverOpened)) {
return undefined;
}
@ -355,7 +373,8 @@ const Modal = ({
)}
<Surface
scrollable
scrollable={!frozenProps.noScrollable}
noPadding={frozenProps.noContentInlinePadding}
className={buildClassName(
styles.content,
shouldShowHeader && styles.withHeader,
@ -366,6 +385,11 @@ const Modal = ({
{frozenProps.children}
</div>
</Surface>
{Boolean(frozenProps.stickyFooter) && (
<div className={styles.stickyFooter}>
{frozenProps.stickyFooter}
</div>
)}
</div>
</dialog>
</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 buildClassName from '../../../util/buildClassName';
import Control, {
ControlDescription,
ControlLabel,
@ -7,11 +9,14 @@ import Control, {
import Interactive from '../layout/Interactive';
import Checkbox from '../primitives/Checkbox';
import styles from './CheckboxField.module.scss';
type Props = Omit<React.ComponentProps<typeof Checkbox>, 'className' | 'disabled'> & {
label: string;
description?: string;
disabled?: boolean;
loading?: boolean;
isCentered?: boolean;
className?: string;
controlClassName?: string;
labelClassName?: string;
@ -24,6 +29,7 @@ const CheckboxField = ({
description,
disabled,
loading,
isCentered,
className,
controlClassName,
labelClassName,
@ -38,9 +44,9 @@ const CheckboxField = ({
ripple={ripple}
disabled={disabled}
loading={loading}
className={className}
className={buildClassName(className, isCentered && styles.centered)}
>
<Control className={controlClassName}>
<Control className={buildClassName(controlClassName, isCentered && styles.centeredControl)}>
<Checkbox {...checkboxProps} />
<ControlLabel className={labelClassName}>{label}</ControlLabel>
{description !== undefined ? (

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,38 @@
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,
.resultLabel {
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 type {
ApiAiComposeToneType, ApiComposedMessageWithAI, ApiFormattedText, ApiInputAiComposeTone,
} from '../../../../api/types';
import type { MenuItemContextAction } from '../../../ui/ListItem';
import type { TabWithProperties } from '../../../ui/TabList';
import { TME_LINK_PREFIX } from '../../../../config';
import { selectTabState } from '../../../../global/selectors';
import { compareAiTones, getInputTone } from '../../../../util/aiComposeTones';
import buildClassName from '../../../../util/buildClassName';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
@ -15,9 +18,12 @@ import useLang from '../../../../hooks/useLang';
import useLastCallback from '../../../../hooks/useLastCallback';
import CheckboxField from '../../../gili/templates/CheckboxField';
import ConfirmDialog from '../../../ui/ConfirmDialog';
import Skeleton from '../../../ui/placeholder/Skeleton';
import TabList from '../../../ui/TabList';
import Transition from '../../../ui/Transition';
import { AiEditorCopyButton, AiEditorErrorMessage, AiEditorResultArea } from './AiEditorShared';
import AiToneEditorModal from './AiToneEditorModal';
import sharedStyles from './AiEditorShared.module.scss';
import modalStyles from './AiMessageEditorModal.module.scss';
@ -35,6 +41,7 @@ type OwnProps = {
type StateProps = {
tones: ApiAiComposeToneType[];
isAiToneEditorOpen?: boolean;
};
const AiTextStyleEditor = ({
@ -46,28 +53,97 @@ const AiTextStyleEditor = ({
error,
isPremium,
tones,
isAiToneEditorOpen,
}: OwnProps & StateProps) => {
const {
setAiMessageEditorStyleOptions,
composeWithAiMessageEditor,
openAiToneEditorModal,
closeAiMessageEditorModal,
deleteAiTone,
openChatWithDraft,
} = getActions();
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 hasRequest = Boolean(selectedTone) || shouldEmojify;
const shouldShowError = Boolean(error) && hasRequest;
const styleTabs = useMemo((): TabWithProperties[] => tones.map((entry) => ({
customEmojiDocumentId: entry.emojiId,
title: entry.title,
})), [tones]);
const buildContextActions = useLastCallback((entry: ApiAiComposeToneType): MenuItemContextAction[] | undefined => {
if (!('id' in entry)) return undefined;
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(
(entry) => compareAiTones(selectedTone, getInputTone(entry)),
);
const handleStyleSelect = useLastCallback((index: number) => {
if (index === tones.length) {
openAiToneEditorModal();
return;
}
const tone = getInputTone(tones[index]);
setAiMessageEditorStyleOptions({ selectedTone: tone });
composeWithAiMessageEditor({ tone, isEmojify: shouldEmojify });
@ -105,15 +181,26 @@ const AiTextStyleEditor = ({
return (
<div className={buildClassName(modalStyles.editorBlock, styles.styleBlock)}>
<TabList
tabs={styleTabs}
activeTab={activeStyleIndex}
onSwitchTab={handleStyleSelect}
className={styles.tabList}
tabClassName={styles.tab}
indicatorClassName={styles.tabListIndicator}
itemAlignment="vertical"
/>
<div className={styles.tabListWrapper}>
{styleTabs.length > 0 && (
<TabList
tabs={styleTabs}
activeTab={activeStyleIndex}
onSwitchTab={handleStyleSelect}
className={styles.tabList}
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} />
@ -144,6 +231,16 @@ const AiTextStyleEditor = ({
textToCopy={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>
);
};
@ -152,6 +249,7 @@ export default memo(withGlobal<OwnProps>(
(global): Complete<StateProps> => {
return {
tones: global.aiComposeTones?.tones ?? MEMO_EMPTY_ARRAY,
isAiToneEditorOpen: Boolean(selectTabState(global).aiToneEditorModal),
};
},
)(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) {
max-width: none;

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import WebAppsCloseConfirmationModal from '../main/WebAppsCloseConfirmationModal
import AiMessageEditorModal from '../middle/composer/AiMessageEditorModal/AiMessageEditorModal.async';
import AboutAdsModal from './aboutAds/AboutAdsModal.async';
import AgeVerificationModal from './ageVerification/AgeVerificationModal.async';
import AiTonePreviewModal from './aiTonePreview/AiTonePreviewModal.async';
import AttachBotInstallModal from './attachBotInstall/AttachBotInstallModal.async';
import BirthdaySetupModal from './birthday/BirthdaySetupModal.async';
import BoostModal from './boost/BoostModal.async';
@ -157,7 +158,8 @@ type ModalKey = keyof Pick<TabState,
'isQuickChatPickerOpen' |
'isCocoonModalOpen' |
'editRankModal' |
'rankModal'
'rankModal' |
'aiTonePreviewModal'
>;
type WrappedModalKey = 'pollModal';
type LegacyModalKey = Exclude<ModalKey, WrappedModalKey>;
@ -293,6 +295,7 @@ const LEGACY_MODALS: LegacyModalRegistry = {
isCocoonModalOpen: CocoonModal,
editRankModal: EditRankModal,
rankModal: RankModal,
aiTonePreviewModal: AiTonePreviewModal,
};
const WRAPPED_MODALS: WrappedModalRegistry = {
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 AnimatedCounter from '../common/AnimatedCounter';
type OwnProps = {
ref?: ElementRef<HTMLInputElement>;
id?: string;
@ -22,6 +24,7 @@ type OwnProps = {
placeholder?: string;
autoComplete?: string;
maxLength?: number;
hasLengthIndicator?: boolean;
tabIndex?: number;
title?: string;
autoFocus?: boolean;
@ -51,6 +54,7 @@ const InputText = ({
autoComplete = 'off',
inputMode,
maxLength,
hasLengthIndicator,
tabIndex,
title,
autoFocus,
@ -109,6 +113,11 @@ const InputText = ({
{labelText && (
<label htmlFor={id}>{labelText}</label>
)}
{hasLengthIndicator && maxLength !== undefined && (
<div className="max-length-indicator">
<AnimatedCounter text={Math.max(0, maxLength - (value || '').length).toString()} />
</div>
)}
</div>
);
};

View File

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

View File

@ -1,4 +1,5 @@
import type { ApiInputAiComposeTone } from '../../../api/types';
import type { ActionReturnType, GlobalState } from '../../types';
import { compareAiTones, getToneCacheKey } from '../../../util/aiComposeTones';
import { getCurrentTabId } from '../../../util/establishMultitabRole';
@ -6,6 +7,36 @@ import { callApi } from '../../../api/gramjs';
import { addActionHandler, getGlobal, setGlobal } from '../../index';
import { updateTabState } from '../../reducers/tabs';
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) {
return `${tone ? getToneCacheKey(tone) : ''}_${emojify ? '1' : '0'}`;
@ -148,6 +179,166 @@ addActionHandler('composeWithAiMessageEditor', async (global, actions, payload):
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> => {
const hash = global.aiComposeTones?.hash;
const result = await callApi('fetchAiComposeTones', { hash });

View File

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

View File

@ -6,6 +6,7 @@ import { updateTabState } from '../../reducers/tabs';
import { selectTabState } from '../../selectors';
import { selectCurrentMessageList } from '../../selectors/messages';
import { selectTranslationLanguage } from '../../selectors/settings';
import { showToneLimitNotification } from '../api/ai';
addActionHandler('openAiMessageEditorModal', (global, actions, payload): ActionReturnType => {
const {
@ -187,3 +188,23 @@ addActionHandler('clearAiMessageEditorPendingResult', (global, actions, payload)
aiMessageEditorPendingResult: undefined,
}, 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 {
ApiAiComposeTone,
ApiAttachBot,
ApiAttachment,
ApiBirthday,
@ -2694,6 +2695,38 @@ export interface ActionPayloads {
scheduleRepeatPeriod?: number;
} & 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: ({
chatId: string;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1533,6 +1533,7 @@ export interface LangPair {
'ViewButtonStickerset': undefined;
'ViewButtonEmojiset': undefined;
'ViewButtonGiftUnique': undefined;
'ViewButtonAiStyle': undefined;
'AuthContinueOnThisLanguage': undefined;
'Share': undefined;
'GiftSortByDate': undefined;
@ -2108,6 +2109,27 @@ export interface LangPair {
'AiMessageEditorApply': undefined;
'AiMessageEditorEmojify': 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;
'TextShowLess': undefined;
'AiMessageEditorFrom': undefined;
@ -3741,6 +3763,22 @@ export interface LangPairWithVariables<V = LangVariable> {
'AiMessageEditorDailyLimitReached': {
'link': V;
};
'AiToneCreated': {
'title': V;
};
'AiToneLimitReachedPremium': {
'limit': V;
};
'AiTonePreviewUsedBy': {
'count': V;
};
'AiTonePreviewCreatedBy': {
'author': V;
};
'AiTonePreviewUsedByCreatedBy': {
'usedBy': V;
'createdBy': V;
};
'UnofficialSecurityRisk': {
'peer': V;
};

View File

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

View File

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