From cdedb486f446932fab1c61c6193ae803b8c61f00 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Thu, 20 Jul 2023 15:58:39 +0200 Subject: [PATCH] Message List: Add ability to translate entire chats (#3464) --- src/api/gramjs/apiBuilders/users.ts | 3 +- src/api/gramjs/methods/chats.ts | 16 + src/api/gramjs/methods/index.ts | 2 +- src/api/types/chats.ts | 4 + src/api/types/users.ts | 1 + src/assets/fonts/icomoon.woff | Bin 58056 -> 58972 bytes src/assets/fonts/icomoon.woff2 | Bin 26832 -> 27132 bytes src/assets/premium/PremiumTranslate.svg | 3 + src/bundles/extra.ts | 2 +- src/components/common/EmbeddedMessage.scss | 8 + src/components/common/EmbeddedMessage.tsx | 25 +- src/components/common/MessageSummary.tsx | 8 +- .../SettingsDoNotTranslate.module.scss | 2 + .../left/settings/SettingsLanguage.tsx | 55 ++- .../main/premium/PremiumFeatureItem.tsx | 12 +- .../main/premium/PremiumFeatureModal.tsx | 5 + .../main/premium/PremiumMainModal.tsx | 3 + .../middle/ChatLanguageModal.async.tsx | 16 + ...ule.scss => ChatLanguageModal.module.scss} | 0 ...anguageModal.tsx => ChatLanguageModal.tsx} | 47 +- src/components/middle/HeaderActions.tsx | 145 +++++- src/components/middle/HeaderMenuContainer.tsx | 23 +- .../middle/MessageLanguageModal.async.tsx | 16 - src/components/middle/MiddleColumn.tsx | 12 +- .../middle/message/ContextMenuContainer.tsx | 28 +- src/components/middle/message/Location.tsx | 2 +- src/components/middle/message/Message.tsx | 36 +- .../message/hooks/useDetectChatLanguage.ts | 104 ++++ .../middle/message/hooks/useInnerHandlers.ts | 5 +- .../message/hooks/useMessageTranslation.ts | 103 +++- src/components/ui/Checkbox.scss | 2 +- src/components/ui/Checkbox.tsx | 6 +- src/global/actions/api/chats.ts | 35 ++ src/global/actions/api/messages.ts | 27 +- src/global/actions/ui/chats.ts | 7 +- src/global/actions/ui/messages.ts | 17 +- src/global/helpers/messages.ts | 16 +- src/global/initialState.ts | 1 + src/global/reducers/translations.ts | 34 ++ src/global/selectors/chats.ts | 47 +- src/global/selectors/messages.ts | 28 +- src/global/selectors/settings.ts | 4 + src/global/types.ts | 29 +- src/hooks/useTextLanguage.ts | 10 +- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.json | 1 + src/styles/Telegram T.json | 461 ++++++++++-------- src/styles/icons.scss | 9 + src/types/index.ts | 2 + src/util/moduleLoader.ts | 4 +- src/util/primitives/LimitedMap.ts | 74 +++ src/util/switchTheme.ts | 22 +- 52 files changed, 1191 insertions(+), 332 deletions(-) create mode 100644 src/assets/premium/PremiumTranslate.svg create mode 100644 src/components/middle/ChatLanguageModal.async.tsx rename src/components/middle/{MessageLanguageModal.module.scss => ChatLanguageModal.module.scss} (100%) rename src/components/middle/{MessageLanguageModal.tsx => ChatLanguageModal.tsx} (75%) delete mode 100644 src/components/middle/MessageLanguageModal.async.tsx create mode 100644 src/components/middle/message/hooks/useDetectChatLanguage.ts create mode 100644 src/util/primitives/LimitedMap.ts diff --git a/src/api/gramjs/apiBuilders/users.ts b/src/api/gramjs/apiBuilders/users.ts index 18f111b58..1ac80ce80 100644 --- a/src/api/gramjs/apiBuilders/users.ts +++ b/src/api/gramjs/apiBuilders/users.ts @@ -16,7 +16,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse fullUser: { about, commonChatsCount, pinnedMsgId, botInfo, blocked, profilePhoto, voiceMessagesForbidden, premiumGifts, - fallbackPhoto, personalPhoto, + fallbackPhoto, personalPhoto, translationsDisabled, }, users, } = mtpUserFull; @@ -29,6 +29,7 @@ export function buildApiUserFullInfo(mtpUserFull: GramJs.users.UserFull): ApiUse pinnedMessageId: pinnedMsgId, isBlocked: Boolean(blocked), noVoiceMessages: voiceMessagesForbidden, + isTranslationDisabled: translationsDisabled, ...(profilePhoto instanceof GramJs.Photo && { profilePhoto: buildApiPhoto(profilePhoto) }), ...(fallbackPhoto instanceof GramJs.Photo && { fallbackPhoto: buildApiPhoto(fallbackPhoto) }), ...(personalPhoto instanceof GramJs.Photo && { personalPhoto: buildApiPhoto(personalPhoto) }), diff --git a/src/api/gramjs/methods/chats.ts b/src/api/gramjs/methods/chats.ts index e3a767ca4..304c7a467 100644 --- a/src/api/gramjs/methods/chats.ts +++ b/src/api/gramjs/methods/chats.ts @@ -412,6 +412,7 @@ async function getFullChatInfo(chatId: string): Promise buildApiPeerId(userId, 'user')), + isTranslationDisabled: translationsDisabled, }, users, userStatusesById, @@ -490,6 +492,7 @@ async function getFullChannelInfo( stickerset, chatPhoto, participantsHidden, + translationsDisabled, } = result.fullChat; if (chatPhoto instanceof GramJs.Photo) { @@ -563,6 +566,7 @@ async function getFullChannelInfo( statisticsDcId: statsDc, stickerSet: stickerset ? buildStickerSet(stickerset) : undefined, areParticipantsHidden: participantsHidden, + isTranslationDisabled: translationsDisabled, }, users: [...(users || []), ...(bannedUsers || []), ...(adminUsers || [])], userStatusesById: statusesById, @@ -1811,3 +1815,15 @@ export async function fetchChatlistInvites({ chats: result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean), }; } + +export function togglePeerTranslations({ + chat, isEnabled, +}: { + chat: ApiChat; + isEnabled: boolean; +}) { + return invokeRequest(new GramJs.messages.TogglePeerTranslations({ + disabled: isEnabled ? undefined : true, + peer: buildInputPeer(chat.id, chat.accessHash), + })); +} diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 678fa2f02..814e46b5e 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -23,7 +23,7 @@ export { getChatByPhoneNumber, toggleJoinToSend, toggleJoinRequest, fetchTopics, deleteTopic, togglePinnedTopic, editTopic, toggleForum, fetchTopicById, createTopic, toggleParticipantsHidden, checkChatlistInvite, joinChatlistInvite, createChalistInvite, editChatlistInvite, deleteChatlistInvite, fetchChatlistInvites, - fetchLeaveChatlistSuggestions, leaveChatlist, + fetchLeaveChatlistSuggestions, leaveChatlist, togglePeerTranslations, } from './chats'; export { diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 7cc984caa..d3cf43a4f 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -76,6 +76,9 @@ export interface ApiChat { unreadReactions?: number[]; unreadMentions?: number[]; + + // Locally determined field + detectedLanguage?: string; } export interface ApiTypingStatus { @@ -114,6 +117,7 @@ export interface ApiChatFullInfo { stickerSet?: ApiStickerSet; profilePhoto?: ApiPhoto; areParticipantsHidden?: boolean; + isTranslationDisabled?: true; } export interface ApiChatMember { diff --git a/src/api/types/users.ts b/src/api/types/users.ts index a272ec23f..51f923271 100644 --- a/src/api/types/users.ts +++ b/src/api/types/users.ts @@ -42,6 +42,7 @@ export interface ApiUserFullInfo { personalPhoto?: ApiPhoto; noVoiceMessages?: boolean; premiumGifts?: ApiPremiumGiftOption[]; + isTranslationDisabled?: true; } export type ApiFakeType = 'fake' | 'scam'; diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 023beb669402681b9784418127c947724476032f..90484108750a520fe9514ae7aea135107254c849 100644 GIT binary patch delta 1033 zcmZuwO=uHQ5Pp+QH=AZRdHHEJG3}-#*-bI_*Ce&tA6jh{E!0AjOGR00ZA8;n6jJq& zco9!x*&=%Av0l7b>Os(hVv8c4`~lHwYZVa@6vTsATIZ!!Q5^W*{Lh>BhIuf@%|GJ2 zxt^{r0tD&(K4L3!O2C+2@{VtE=RXZ(^-)3~gN{B1IsK%2NI$`Bw^0{D9N9mhrn zFs^lJ8H-+GxVf1Bkk!ZV;&&66SXsnN_%^I(2hoO?!a0U3yRAP)M^2o?_cV_$_rRRd z4SQg2<}&Cn@+LA#DA&(*k_K`Hqg!EfgSP@2TR~R90ldWy7Cr`{XbnWQgfpH*x13mG z#aLM`^YGyi^J(D_U*;@oE%1L0i^|?@(2#6{q!!iqJYMlT)wD%$S+#_Xc2ovs`=B!r z=!{lv^mQ~frTzYdX0clmYN^v&T3ueg$tJiZ$>p?()Fnx7ik#_Gv^hPV&YGIIuVW-I zXtoG~)4bDGk9NvAICDb#Ch*-zAqz zf+~?xapgIazZb4#@RZbF;P$Bse+1BNTm+4=na~-;UCW0;{IxDzwx?G0nS)G?ll&tN}_JB z2d9mrtc5im)#7b9^>|~lAqaTLUXKH0W3|6NKOjlG!zxfdm*c5mb?}mu%Sn6lSC*rZ6zv{sB~D2Er=!t>qa&K?a69 z20*?F2(uqyY0bzjsQ`)r&1aSYVZj>#GCBFlKy`NaK^9E_;X6)(b8-_afc%F*eGCd< ze1q|8USe)4Pz=atYy;t$UYx%R@{3D={#h~ck3Zv~$v%wUjE6SQU`)KF%m~y4#Qby1 yAI9_Bd}ZKf0SYiMJXpVA5{#Za=e{}P@y%E6%P?>L^JoGi8#r)|Z=Ut!5F-FnR99;N diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index b41c2175620e5a472116bd0682c62107eda1261e..ae34270fb2f309a8101f1490c144d3539df8a658 100644 GIT binary patch literal 27132 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 DW!|28wZbB8{M`wObprqX>FfxNeUeMs^E7Ak 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 + + diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index c4a1bafcf..2958fade4 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -29,7 +29,7 @@ export { default as MessageSelectToolbar } from '../components/middle/MessageSel export { default as SeenByModal } from '../components/common/SeenByModal'; export { default as ReactorListModal } from '../components/middle/ReactorListModal'; export { default as EmojiInteractionAnimation } from '../components/middle/EmojiInteractionAnimation'; -export { default as MessageLanguageModal } from '../components/middle/MessageLanguageModal'; +export { default as ChatLanguageModal } from '../components/middle/ChatLanguageModal'; export { default as LeftSearch } from '../components/left/search/LeftSearch'; export { default as Settings } from '../components/left/settings/Settings'; diff --git a/src/components/common/EmbeddedMessage.scss b/src/components/common/EmbeddedMessage.scss index 3dbbb407a..bb7670da2 100644 --- a/src/components/common/EmbeddedMessage.scss +++ b/src/components/common/EmbeddedMessage.scss @@ -172,4 +172,12 @@ color: var(--color-text-secondary); } } + + .embed-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } } diff --git a/src/components/common/EmbeddedMessage.tsx b/src/components/common/EmbeddedMessage.tsx index 148397d42..2fb4b3518 100644 --- a/src/components/common/EmbeddedMessage.tsx +++ b/src/components/common/EmbeddedMessage.tsx @@ -1,9 +1,11 @@ -import type { FC } from '../../lib/teact/teact'; import React, { useRef } from '../../lib/teact/teact'; +import type { FC } from '../../lib/teact/teact'; import type { ApiUser, ApiMessage, ApiChat, } from '../../api/types'; +import type { ChatTranslatedMessages } from '../../global/types'; +import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { getMessageMediaHash, @@ -12,21 +14,24 @@ import { getMessageRoundVideo, getUserColorKey, getMessageIsSpoiler, + isMessageTranslatable, } from '../../global/helpers'; import renderText from './helpers/renderText'; import { getPictogramDimensions } from './helpers/mediaDimensions'; import buildClassName from '../../util/buildClassName'; -import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useMedia from '../../hooks/useMedia'; import useThumbnail from '../../hooks/useThumbnail'; import useLang from '../../hooks/useLang'; import { useFastClick } from '../../hooks/useFastClick'; +import useMessageTranslation from '../middle/message/hooks/useMessageTranslation'; +import useShowTransition from '../../hooks/useShowTransition'; import ActionMessage from '../middle/ActionMessage'; import MessageSummary from './MessageSummary'; import MediaSpoiler from './MediaSpoiler'; +import Skeleton from '../ui/Skeleton'; import './EmbeddedMessage.scss'; @@ -39,6 +44,8 @@ type OwnProps = { noUserColors?: boolean; isProtected?: boolean; hasContextMenu?: boolean; + chatTranslations?: ChatTranslatedMessages; + requestedChatTranslationLanguage?: string; observeIntersectionForLoading?: ObserveFn; observeIntersectionForPlaying?: ObserveFn; onClick: NoneToVoidFunction; @@ -55,6 +62,8 @@ const EmbeddedMessage: FC = ({ isProtected, noUserColors, hasContextMenu, + chatTranslations, + requestedChatTranslationLanguage, observeIntersectionForLoading, observeIntersectionForPlaying, onClick, @@ -68,6 +77,16 @@ const EmbeddedMessage: FC = ({ const isRoundVideo = Boolean(message && getMessageRoundVideo(message)); const isSpoiler = Boolean(message && getMessageIsSpoiler(message)); + const shouldTranslate = message && isMessageTranslatable(message); + const { isPending: isTranslationPending, translatedText } = useMessageTranslation( + chatTranslations, message?.chatId, shouldTranslate ? message?.id : undefined, requestedChatTranslationLanguage, + ); + + const { + shouldRender: shouldRenderLoader, + transitionClassNames, + } = useShowTransition(isTranslationPending || (!message && !customText)); + const lang = useLang(); const senderTitle = sender ? getSenderTitle(lang, sender) : message?.forwardInfo?.hiddenUserName; @@ -85,6 +104,7 @@ const EmbeddedMessage: FC = ({ onClick={message && handleClick} onMouseDown={message && handleMouseDown} > + {shouldRenderLoader && } {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}

@@ -102,6 +122,7 @@ const EmbeddedMessage: FC = ({ lang={lang} message={message} noEmoji={Boolean(mediaThumbnail)} + translatedText={translatedText} observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForPlaying={observeIntersectionForPlaying} /> diff --git a/src/components/common/MessageSummary.tsx b/src/components/common/MessageSummary.tsx index 76bab42e6..c6a75983f 100644 --- a/src/components/common/MessageSummary.tsx +++ b/src/components/common/MessageSummary.tsx @@ -1,6 +1,6 @@ import React, { memo } from '../../lib/teact/teact'; -import type { ApiMessage } from '../../api/types'; +import type { ApiFormattedText, ApiMessage } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { LangFn } from '../../hooks/useLang'; @@ -20,6 +20,7 @@ import MessageText from './MessageText'; interface OwnProps { lang: LangFn; message: ApiMessage; + translatedText?: ApiFormattedText; noEmoji?: boolean; highlight?: string; truncateLength?: number; @@ -33,6 +34,7 @@ interface OwnProps { function MessageSummary({ lang, message, + translatedText, noEmoji = false, highlight, truncateLength = TRUNCATED_SUMMARY_LENGTH, @@ -47,7 +49,8 @@ function MessageSummary({ const hasCustomEmoji = entities?.some((e) => e.type === ApiMessageEntityTypes.CustomEmoji); if (!text || (!hasSpoilers && !hasCustomEmoji)) { - const trimmedText = trimText(getMessageSummaryText(lang, message, noEmoji), truncateLength); + const summaryText = translatedText?.text || getMessageSummaryText(lang, message, noEmoji); + const trimmedText = trimText(summaryText, truncateLength); return ( @@ -64,6 +67,7 @@ function MessageSummary({ return ( void; }; -type StateProps = Pick; +type StateProps = { + isCurrentUserPremium: boolean; +} & Pick; const SettingsLanguage: FC = ({ isActive, + isCurrentUserPremium, languages, language, canTranslate, + canTranslateChats, doNotTranslate, onScreenSelect, onReset, @@ -41,11 +47,14 @@ const SettingsLanguage: FC = ({ loadLanguages, loadAttachBots, setSettingOption, + openPremiumModal, } = getActions(); const [selectedLanguage, setSelectedLanguage] = useState(language); const [isLoading, markIsLoading, unmarkIsLoading] = useFlag(); + const canTranslateChatsEnabled = isCurrentUserPremium && canTranslateChats; + const lang = useLang(); useEffect(() => { @@ -54,7 +63,7 @@ const SettingsLanguage: FC = ({ } }, [languages]); - const handleChange = useCallback((langCode: string) => { + const handleChange = useLastCallback((langCode: string) => { setSelectedLanguage(langCode); markIsLoading(); @@ -65,15 +74,27 @@ const SettingsLanguage: FC = ({ loadAttachBots(); // Should be refetched every language change }); - }, [markIsLoading, unmarkIsLoading, setSettingOption, loadAttachBots]); + }); const options = useMemo(() => { return languages ? buildOptions(languages) : undefined; }, [languages]); - const handleShouldTranslateChange = useCallback((newValue: boolean) => { + const handleShouldTranslateChange = useLastCallback((newValue: boolean) => { setSettingOption({ canTranslate: newValue }); - }, [setSettingOption]); + }); + + const handleShouldTranslateChatsChange = useLastCallback((newValue: boolean) => { + setSettingOption({ canTranslateChats: newValue }); + }); + + const handleShouldTranslateChatsClick = useLastCallback(() => { + if (!isCurrentUserPremium) { + openPremiumModal({ + initialSection: 'translations', + }); + } + }); const doNotTranslateText = useMemo(() => { if (!IS_TRANSLATION_SUPPORTED || !doNotTranslate.length) { @@ -88,9 +109,9 @@ const SettingsLanguage: FC = ({ return lang('Languages', doNotTranslate.length); }, [doNotTranslate, lang, language]); - const handleDoNotSelectOpen = useCallback(() => { + const handleDoNotSelectOpen = useLastCallback(() => { onScreenSelect(SettingsScreens.DoNotTranslate); - }, [onScreenSelect]); + }); useHistoryBack({ isActive, @@ -102,12 +123,20 @@ const SettingsLanguage: FC = ({ {IS_TRANSLATION_SUPPORTED && (

- {canTranslate && ( + + {(canTranslate || canTranslateChatsEnabled) && ( @@ -154,13 +183,17 @@ function buildOptions(languages: ApiLanguage[]) { export default memo(withGlobal( (global): StateProps => { const { - language, languages, canTranslate, doNotTranslate, + language, languages, canTranslate, canTranslateChats, doNotTranslate, } = global.settings.byKey; + const isCurrentUserPremium = selectIsCurrentUserPremium(global); + return { + isCurrentUserPremium, languages, language, canTranslate, + canTranslateChats, doNotTranslate, }; }, diff --git a/src/components/main/premium/PremiumFeatureItem.tsx b/src/components/main/premium/PremiumFeatureItem.tsx index 1e8f611b5..ebe2960eb 100644 --- a/src/components/main/premium/PremiumFeatureItem.tsx +++ b/src/components/main/premium/PremiumFeatureItem.tsx @@ -2,6 +2,7 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo } from '../../../lib/teact/teact'; import renderText from '../../common/helpers/renderText'; +import { hexToRgb, lerpRgb } from '../../../util/switchTheme'; import ListItem from '../../ui/ListItem'; @@ -13,23 +14,30 @@ type OwnProps = { text: string; onClick: VoidFunction; index: number; + count: number; }; const COLORS = [ '#F2862D', '#EB7B4D', '#E46D72', '#DD6091', '#CC5FBA', '#B464E7', '#9873FF', '#768DFF', '#55A5FC', '#52B0C9', '#4FBC93', '#4CC663', -]; +].map(hexToRgb); const PremiumFeatureItem: FC = ({ icon, title, text, index, + count, onClick, }) => { + const newIndex = (index / count) * COLORS.length; + const colorA = COLORS[Math.floor(newIndex)]; + const colorB = COLORS[Math.ceil(newIndex)] ?? colorA; + const { r, g, b } = lerpRgb(colorA, colorB, 1); + return ( - +
{renderText(title, ['br'])}
{text}
diff --git a/src/components/main/premium/PremiumFeatureModal.tsx b/src/components/main/premium/PremiumFeatureModal.tsx index b584dbb3a..70c4644b2 100644 --- a/src/components/main/premium/PremiumFeatureModal.tsx +++ b/src/components/main/premium/PremiumFeatureModal.tsx @@ -35,6 +35,7 @@ export const PREMIUM_FEATURE_TITLES: Record = { advanced_chat_management: 'PremiumPreviewAdvancedChatManagement', animated_userpics: 'PremiumPreviewAnimatedProfiles', emoji_status: 'PremiumPreviewEmojiStatus', + translations: 'PremiumPreviewTranslations', }; export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { @@ -50,6 +51,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record = { advanced_chat_management: 'PremiumPreviewAdvancedChatManagementDescription', animated_userpics: 'PremiumPreviewAnimatedProfilesDescription', emoji_status: 'PremiumPreviewEmojiStatusDescription', + translations: 'PremiumPreviewTranslationsDescription', }; export const PREMIUM_FEATURE_SECTIONS = [ @@ -65,15 +67,18 @@ export const PREMIUM_FEATURE_SECTIONS = [ 'profile_badge', 'animated_userpics', 'emoji_status', + 'translations', ]; const PREMIUM_BOTTOM_VIDEOS: string[] = [ 'faster_download', 'voice_to_text', 'advanced_chat_management', + 'infinite_reactions', 'profile_badge', 'animated_userpics', 'emoji_status', + 'translations', ]; type ApiLimitTypeWithoutUpload = Exclude; diff --git a/src/components/main/premium/PremiumMainModal.tsx b/src/components/main/premium/PremiumMainModal.tsx index 28e4e6d97..abbc23a3c 100644 --- a/src/components/main/premium/PremiumMainModal.tsx +++ b/src/components/main/premium/PremiumMainModal.tsx @@ -42,6 +42,7 @@ import PremiumBadge from '../../../assets/premium/PremiumBadge.svg'; import PremiumVideo from '../../../assets/premium/PremiumVideo.svg'; import PremiumEmoji from '../../../assets/premium/PremiumEmoji.svg'; import PremiumStatus from '../../../assets/premium/PremiumStatus.svg'; +import PremiumTranslate from '../../../assets/premium/PremiumTranslate.svg'; import styles from './PremiumMainModal.module.scss'; @@ -60,6 +61,7 @@ const PREMIUM_FEATURE_COLOR_ICONS: Record = { advanced_chat_management: PremiumChats, animated_userpics: PremiumVideo, emoji_status: PremiumStatus, + translations: PremiumTranslate, }; export type OwnProps = { @@ -275,6 +277,7 @@ const PremiumMainModal: FC = ({ : lang(PREMIUM_FEATURE_DESCRIPTIONS[section])} icon={PREMIUM_FEATURE_COLOR_ICONS[section]} index={index} + count={filteredSections.length} onClick={handleOpen(section)} /> ); diff --git a/src/components/middle/ChatLanguageModal.async.tsx b/src/components/middle/ChatLanguageModal.async.tsx new file mode 100644 index 000000000..a116bbc0b --- /dev/null +++ b/src/components/middle/ChatLanguageModal.async.tsx @@ -0,0 +1,16 @@ +import type { FC } from '../../lib/teact/teact'; +import React from '../../lib/teact/teact'; +import type { OwnProps } from './ChatLanguageModal'; + +import { Bundles } from '../../util/moduleLoader'; +import useModuleLoader from '../../hooks/useModuleLoader'; + +const ChatLanguageModalAsync: FC = (props) => { + const { isOpen } = props; + const ChatLanguageModal = useModuleLoader(Bundles.Extra, 'ChatLanguageModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return ChatLanguageModal ? : undefined; +}; + +export default ChatLanguageModalAsync; diff --git a/src/components/middle/MessageLanguageModal.module.scss b/src/components/middle/ChatLanguageModal.module.scss similarity index 100% rename from src/components/middle/MessageLanguageModal.module.scss rename to src/components/middle/ChatLanguageModal.module.scss diff --git a/src/components/middle/MessageLanguageModal.tsx b/src/components/middle/ChatLanguageModal.tsx similarity index 75% rename from src/components/middle/MessageLanguageModal.tsx rename to src/components/middle/ChatLanguageModal.tsx index b76bc3145..8fab50f2f 100644 --- a/src/components/middle/MessageLanguageModal.tsx +++ b/src/components/middle/ChatLanguageModal.tsx @@ -5,7 +5,12 @@ import { getActions, withGlobal } from '../../global'; import type { FC } from '../../lib/teact/teact'; -import { selectLanguageCode, selectRequestedTranslationLanguage, selectTabState } from '../../global/selectors'; +import { + selectLanguageCode, + selectRequestedChatTranslationLanguage, + selectRequestedMessageTranslationLanguage, + selectTabState, +} from '../../global/selectors'; import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../config'; import buildClassName from '../../util/buildClassName'; import renderText from '../common/helpers/renderText'; @@ -17,7 +22,7 @@ import Modal from '../ui/Modal'; import ListItem from '../ui/ListItem'; import InputText from '../ui/InputText'; -import styles from './MessageLanguageModal.module.scss'; +import styles from './ChatLanguageModal.module.scss'; type LanguageItem = { langCode: string; @@ -36,23 +41,34 @@ type StateProps = { currentLanguageCode: string; }; -const MessageLanguageModal: FC = ({ +const ChatLanguageModal: FC = ({ isOpen, chatId, messageId, activeTranslationLanguage, currentLanguageCode, }) => { - const { requestMessageTranslation, closeMessageLanguageModal } = getActions(); + const { + requestMessageTranslation, + closeChatLanguageModal, + setSettingOption, + requestChatTranslation, + } = getActions(); const [search, setSearch] = useState(''); const lang = useLang(); - const handleSelect = useLastCallback((toLanguageCode: string) => { - if (!chatId || !messageId) return; + const handleSelect = useLastCallback((langCode: string) => { + if (!chatId) return; - requestMessageTranslation({ chatId, id: messageId, toLanguageCode }); - closeMessageLanguageModal(); + if (messageId) { + requestMessageTranslation({ chatId, id: messageId, toLanguageCode: langCode }); + } else { + setSettingOption({ translationLanguage: langCode }); + requestChatTranslation({ chatId, toLanguageCode: langCode }); + } + + closeChatLanguageModal(); }); const handleSearch = useLastCallback((e: React.ChangeEvent) => { @@ -96,7 +112,7 @@ const MessageLanguageModal: FC = ({ isOpen={isOpen} hasCloseButton title={lang('Language')} - onClose={closeMessageLanguageModal} + onClose={closeChatLanguageModal} > = ({ {filteredDisplayedLanguages.map(({ langCode, originalName, translatedName }) => ( = ({ export default memo(withGlobal( (global): StateProps => { - const { chatId, messageId } = selectTabState(global).messageLanguageModal || {}; + const { chatId, messageId } = selectTabState(global).chatLanguageModal || {}; const currentLanguageCode = selectLanguageCode(global); - const activeTranslationLanguage = chatId && messageId - ? selectRequestedTranslationLanguage(global, chatId, messageId) : undefined; + const activeTranslationLanguage = chatId + ? messageId + ? selectRequestedMessageTranslationLanguage(global, chatId, messageId) + : selectRequestedChatTranslationLanguage(global, chatId) + : undefined; return { chatId, @@ -145,4 +164,4 @@ export default memo(withGlobal( currentLanguageCode, }; }, -)(MessageLanguageModal)); +)(ChatLanguageModal)); diff --git a/src/components/middle/HeaderActions.tsx b/src/components/middle/HeaderActions.tsx index 12eb0969f..22f88f254 100644 --- a/src/components/middle/HeaderActions.tsx +++ b/src/components/middle/HeaderActions.tsx @@ -1,8 +1,10 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { memo, useRef, useState } from '../../lib/teact/teact'; +import React, { + memo, useMemo, useRef, useState, +} from '../../lib/teact/teact'; import { requestMeasure, requestNextMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; +import type { FC } from '../../lib/teact/teact'; import type { MessageListType } from '../../global/types'; import { MAIN_THREAD_ID } from '../../api/types'; import type { IAnchorPosition } from '../../types'; @@ -15,6 +17,7 @@ import { import { selectBot, selectCanAnimateInterface, + selectCanTranslateChat, selectChat, selectChatFullInfo, selectIsChatBotNotStarted, @@ -22,6 +25,10 @@ import { selectIsInSelectMode, selectIsRightColumnShown, selectIsUserBlocked, + selectLanguageCode, + selectRequestedChatTranslationLanguage, + selectTranslationLanguage, + selectUserFullInfo, } from '../../global/selectors'; import useLastCallback from '../../hooks/useLastCallback'; @@ -30,6 +37,9 @@ import { useHotkeys } from '../../hooks/useHotkeys'; import Button from '../ui/Button'; import HeaderMenuContainer from './HeaderMenuContainer.async'; +import DropdownMenu from '../ui/DropdownMenu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; interface OwnProps { chatId: string; @@ -59,6 +69,12 @@ interface StateProps { shouldJoinToSend?: boolean; shouldSendJoinRequest?: boolean; noAnimation?: boolean; + canTranslate?: boolean; + isTranslating?: boolean; + translationLanguage: string; + language: string; + detectedChatLanguage?: string; + doNotTranslate: string[]; } // Chrome breaks layout when focusing input during transition @@ -87,6 +103,12 @@ const HeaderActions: FC = ({ shouldJoinToSend, shouldSendJoinRequest, noAnimation, + canTranslate, + isTranslating, + translationLanguage, + language, + detectedChatLanguage, + doNotTranslate, onTopicSearch, }) => { const { @@ -98,6 +120,10 @@ const HeaderActions: FC = ({ requestNextManagementScreen, showNotification, openChat, + requestChatTranslation, + togglePeerTranslations, + openChatLanguageModal, + setSettingOption, } = getActions(); // eslint-disable-next-line no-null/no-null const menuButtonRef = useRef(null); @@ -136,6 +162,15 @@ const HeaderActions: FC = ({ restartBot({ chatId }); }); + const handleTranslateClick = useLastCallback(() => { + if (isTranslating) { + requestChatTranslation({ chatId, toLanguageCode: undefined }); + return; + } + + requestChatTranslation({ chatId, toLanguageCode: translationLanguage }); + }); + const handleJoinRequestsClick = useLastCallback(() => { requestNextManagementScreen({ screen: ManagementScreens.JoinRequests }); }); @@ -179,12 +214,91 @@ const HeaderActions: FC = ({ handleSearchClick(); }); + const getTextWithLanguage = useLastCallback((langKey: string, langCode: string) => { + const simplified = langCode.split('-')[0]; + const translationKey = `TranslateLanguage${simplified.toUpperCase()}`; + const name = lang(translationKey); + if (name !== translationKey) { + return lang(langKey, name); + } + + const translatedNames = new Intl.DisplayNames([language], { type: 'language' }); + const translatedName = translatedNames.of(langCode)!; + return lang(`${langKey}Other`, translatedName); + }); + + const buttonText = useMemo(() => { + if (isTranslating) return lang('ShowOriginalButton'); + + return getTextWithLanguage('TranslateToButton', translationLanguage); + }, [translationLanguage, getTextWithLanguage, isTranslating, lang]); + + const doNotTranslateText = useMemo(() => { + if (!detectedChatLanguage) return undefined; + + return getTextWithLanguage('DoNotTranslateLanguage', detectedChatLanguage); + }, [getTextWithLanguage, detectedChatLanguage]); + + const handleHide = useLastCallback(() => { + togglePeerTranslations({ chatId, isEnabled: false }); + requestChatTranslation({ chatId, toLanguageCode: undefined }); + }); + + const handleChangeLanguage = useLastCallback(() => { + openChatLanguageModal({ chatId }); + }); + + const handleDoNotTranslate = useLastCallback(() => { + if (!detectedChatLanguage) return; + + setSettingOption({ + doNotTranslate: [...doNotTranslate, detectedChatLanguage], + }); + requestChatTranslation({ chatId, toLanguageCode: undefined }); + + showNotification({ message: getTextWithLanguage('AddedToDoNotTranslate', detectedChatLanguage) }); + }); + useHotkeys({ 'Mod+F': handleHotkeySearchClick, }); + const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { + return ({ onTrigger, isOpen }) => ( + + ); + }, [isRightColumnShown, lang]); + return (
+ {canTranslate && ( + + + {buttonText} + + + {lang('Chat.Translate.Menu.To')} + + + {detectedChatLanguage + && {doNotTranslateText}} + {lang('Hide')} + + )} {!isMobile && ( <> {canExpandActions && !shouldSendJoinRequest && (canSubscribe || shouldJoinToSend) && ( @@ -234,9 +348,9 @@ const HeaderActions: FC = ({ color="translucent" size="smaller" onClick={handleSearchClick} - ariaLabel="Search in this chat" + ariaLabel={lang('Conversation.SearchPlaceholder')} > - + )} {canCall && ( @@ -248,7 +362,7 @@ const HeaderActions: FC = ({ onClick={handleRequestCall} ariaLabel="Call" > - + )} @@ -263,7 +377,7 @@ const HeaderActions: FC = ({ onClick={handleJoinRequestsClick} ariaLabel={isChannel ? lang('SubscribeRequests') : lang('MemberRequests')} > - +
{pendingJoinRequests}
)} @@ -278,7 +392,7 @@ const HeaderActions: FC = ({ ariaLabel="More actions" onClick={handleHeaderMenuOpen} > - + {menuPosition && ( ( }): StateProps => { const chat = selectChat(global, chatId); const isChannel = Boolean(chat && isChatChannel(chat)); + const language = selectLanguageCode(global); + const translationLanguage = selectTranslationLanguage(global); + const { doNotTranslate } = global.settings.byKey; if (!chat || chat.isRestricted || selectIsInSelectMode(global)) { return { noMenu: true, + language, + translationLanguage, + doNotTranslate, }; } const bot = selectBot(global, chatId); const chatFullInfo = !isUserId(chatId) ? selectChatFullInfo(global, chatId) : undefined; + const userFullInfo = isUserId(chatId) ? selectUserFullInfo(global, chatId) : undefined; + const fullInfo = chatFullInfo || userFullInfo; const isChatWithSelf = selectIsChatWithSelf(global, chatId); const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID; const isDiscussionThread = messageListType === 'thread' && threadId !== MAIN_THREAD_ID; @@ -350,6 +472,9 @@ export default memo(withGlobal( const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest); const noAnimation = !selectCanAnimateInterface(global); + const isTranslating = Boolean(selectRequestedChatTranslationLanguage(global, chatId)); + const canTranslate = selectCanTranslateChat(global, chatId) && !fullInfo?.isTranslationDisabled; + return { noMenu: false, isChannel, @@ -368,6 +493,12 @@ export default memo(withGlobal( shouldJoinToSend, shouldSendJoinRequest, noAnimation, + canTranslate, + isTranslating, + translationLanguage, + language, + doNotTranslate, + detectedChatLanguage: chat.detectedLanguage, }; }, )(HeaderActions)); diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index abf46dbb4..3b81d863b 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -21,7 +21,7 @@ import { selectTabState, selectUser, selectUserFullInfo, - selectCanManage, selectIsRightColumnShown, + selectCanManage, selectIsRightColumnShown, selectCanTranslateChat, } from '../../global/selectors'; import { getCanAddContact, @@ -86,6 +86,7 @@ export type OwnProps = { canEnterVoiceChat?: boolean; canCreateVoiceChat?: boolean; pendingJoinRequests?: number; + canTranslate?: boolean; onSubscribeChannel: () => void; onSearchClick: () => void; onAsMessagesClick: () => void; @@ -111,6 +112,7 @@ type StateProps = { isChatInfoShown?: boolean; isRightColumnShown?: boolean; canManage?: boolean; + canTranslate?: boolean; }; const CLOSE_MENU_ANIMATION_DURATION = 200; @@ -150,6 +152,7 @@ const HeaderMenuContainer: FC = ({ canEditTopic, canManage, isRightColumnShown, + canTranslate, onJoinRequestsClick, onSubscribeChannel, onSearchClick, @@ -174,6 +177,7 @@ const HeaderMenuContainer: FC = ({ openEditTopicPanel, openChat, toggleManagement, + togglePeerTranslations, } = getActions(); const { isMobile } = useAppLayout(); @@ -323,6 +327,11 @@ const HeaderMenuContainer: FC = ({ closeMenu(); }); + const handleEnableTranslations = useLastCallback(() => { + togglePeerTranslations({ chatId, isEnabled: true }); + closeMenu(); + }); + const handleSelectMessages = useLastCallback(() => { enterMessageSelectMode(); closeMenu(); @@ -538,6 +547,14 @@ const HeaderMenuContainer: FC = ({ {lang('Statistics')} )} + {canTranslate && ( + + {lang('lng_context_translate')} + + )} {canReportChat && ( ( const chatBot = chatId !== REPLIES_USER_ID ? selectBot(global, chatId) : undefined; const userFullInfo = isPrivate ? selectUserFullInfo(global, chatId) : undefined; const chatFullInfo = !isPrivate ? selectChatFullInfo(global, chatId) : undefined; + const fullInfo = userFullInfo || chatFullInfo; const canGiftPremium = Boolean( userFullInfo?.premiumGifts?.length && !selectIsPremiumPurchaseBlocked(global), @@ -625,6 +643,8 @@ export default memo(withGlobal( ); const canEditTopic = topic && getCanManageTopic(chat, topic); const canManage = selectCanManage(global, chatId); + // Context menu item should only be displayed if user hid translation panel + const canTranslate = selectCanTranslateChat(global, chatId) && fullInfo?.isTranslationDisabled; return { chat, @@ -644,6 +664,7 @@ export default memo(withGlobal( canEditTopic, canManage, isRightColumnShown: selectIsRightColumnShown(global), + canTranslate, }; }, )(HeaderMenuContainer)); diff --git a/src/components/middle/MessageLanguageModal.async.tsx b/src/components/middle/MessageLanguageModal.async.tsx deleted file mode 100644 index 3cbbb42bb..000000000 --- a/src/components/middle/MessageLanguageModal.async.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React from '../../lib/teact/teact'; -import type { OwnProps } from './MessageLanguageModal'; - -import { Bundles } from '../../util/moduleLoader'; -import useModuleLoader from '../../hooks/useModuleLoader'; - -const MessageLanguageModalAsync: FC = (props) => { - const { isOpen } = props; - const MessageLanguageModal = useModuleLoader(Bundles.Extra, 'MessageLanguageModal', !isOpen); - - // eslint-disable-next-line react/jsx-props-no-spreading - return MessageLanguageModal ? : undefined; -}; - -export default MessageLanguageModalAsync; diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 1971a8c9e..22c64d924 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -87,7 +87,7 @@ import SeenByModal from '../common/SeenByModal.async'; import EmojiInteractionAnimation from './EmojiInteractionAnimation.async'; import ReactorListModal from './ReactorListModal.async'; import GiftPremiumModal from '../main/premium/GiftPremiumModal.async'; -import MessageLanguageModal from './MessageLanguageModal.async'; +import ChatLanguageModal from './ChatLanguageModal.async'; import './MiddleColumn.scss'; @@ -126,7 +126,7 @@ type StateProps = { isSeenByModalOpen: boolean; isReactorListModalOpen: boolean; isGiftPremiumModalOpen?: boolean; - isMessageLanguageModalOpen?: boolean; + isChatLanguageModalOpen?: boolean; withInterfaceAnimations?: boolean; shouldSkipHistoryAnimations?: boolean; currentTransitionKey: number; @@ -180,7 +180,7 @@ function MiddleColumn({ isSeenByModalOpen, isReactorListModalOpen, isGiftPremiumModalOpen, - isMessageLanguageModalOpen, + isChatLanguageModalOpen, withInterfaceAnimations, shouldSkipHistoryAnimations, currentTransitionKey, @@ -622,7 +622,7 @@ function MiddleColumn({ /> - {IS_TRANSLATION_SUPPORTED && } + {IS_TRANSLATION_SUPPORTED && }
@@ -668,7 +668,7 @@ export default memo(withGlobal( const { messageLists, isLeftColumnShown, activeEmojiInteractions, seenByModal, giftPremiumModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations, - messageLanguageModal, + chatLanguageModal, } = selectTabState(global); const currentMessageList = selectCurrentMessageList(global); const { leftColumnWidth } = global; @@ -686,7 +686,7 @@ export default memo(withGlobal( isSeenByModalOpen: Boolean(seenByModal), isReactorListModalOpen: Boolean(reactorModal), isGiftPremiumModalOpen: giftPremiumModal?.isOpen, - isMessageLanguageModalOpen: Boolean(messageLanguageModal), + isChatLanguageModalOpen: Boolean(chatLanguageModal), withInterfaceAnimations: selectCanAnimateInterface(global), currentTransitionKey: Math.max(0, messageLists.length - 1), activeEmojiInteractions, diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index f4c04e04a..6f87e47d1 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -22,10 +22,12 @@ import { selectIsMessageProtected, selectIsPremiumPurchaseBlocked, selectIsReactionPickerOpen, + selectCanTranslateMessage, selectMessageCustomEmojiSets, selectMessageTranslations, - selectRequestedTranslationLanguage, + selectRequestedMessageTranslationLanguage, selectStickerSet, + selectRequestedChatTranslationLanguage, } from '../../../global/selectors'; import { isActionMessage, @@ -37,10 +39,8 @@ import { isMessageLocal, getMessageVideo, getChatMessageLink, - isServiceNotificationMessage, } from '../../../global/helpers'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config'; -import { IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import { copyTextToClipboard } from '../../../util/clipboard'; @@ -193,7 +193,7 @@ const ContextMenuContainer: FC = ({ toggleReaction, requestMessageTranslation, showOriginalMessage, - openMessageLanguageModal, + openChatLanguageModal, openReactionPicker, } = getActions(); @@ -455,9 +455,9 @@ const ContextMenuContainer: FC = ({ }); const handleSelectLanguage = useLastCallback(() => { - openMessageLanguageModal({ + openChatLanguageModal({ chatId: message.chatId, - id: message.id, + messageId: message.id, }); closeMenu(); }); @@ -610,7 +610,6 @@ export default memo(withGlobal( const isScheduled = messageListType === 'scheduled'; const isChannel = chat && isChatChannel(chat); const isLocal = isMessageLocal(message); - const isServiceNotification = isServiceNotificationMessage(message); const canShowSeenBy = Boolean(!isLocal && chat && seenByMaxChatMembers @@ -634,17 +633,12 @@ export default memo(withGlobal( const customEmojiSets = customEmojiSetsNotFiltered?.every(Boolean) ? customEmojiSetsNotFiltered : undefined; - const translationRequestLanguage = selectRequestedTranslationLanguage(global, message.chatId, message.id); + const translationRequestLanguage = selectRequestedMessageTranslationLanguage(global, message.chatId, message.id); const hasTranslation = translationRequestLanguage ? Boolean(selectMessageTranslations(global, message.chatId, translationRequestLanguage)[message.id]?.text) : undefined; - - const { canTranslate: isTranslationEnabled, doNotTranslate } = global.settings.byKey; - - const canTranslateLanguage = !detectedLanguage || !doNotTranslate.includes(detectedLanguage); - const canTranslate = IS_TRANSLATION_SUPPORTED && isTranslationEnabled && message.content.text - && canTranslateLanguage && !isLocal && !isServiceNotification && !isScheduled && !isAction && !hasTranslation - && !message.emojiOnlyCount; + const canTranslate = !hasTranslation && selectCanTranslateMessage(global, message, detectedLanguage); + const isChatTranslated = selectRequestedChatTranslationLanguage(global, message.chatId); return { availableReactions: global.availableReactions, @@ -683,8 +677,8 @@ export default memo(withGlobal( canScheduleUntilOnline: selectCanScheduleUntilOnline(global, message.chatId), threadId, canTranslate, - canShowOriginal: hasTranslation, - canSelectLanguage: hasTranslation, + canShowOriginal: hasTranslation && !isChatTranslated, + canSelectLanguage: hasTranslation && !isChatTranslated, canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global), isReactionPickerOpen: selectIsReactionPickerOpen(global), }; diff --git a/src/components/middle/message/Location.tsx b/src/components/middle/message/Location.tsx index 6f8d60549..8df604a2d 100644 --- a/src/components/middle/message/Location.tsx +++ b/src/components/middle/message/Location.tsx @@ -70,7 +70,7 @@ const Location: FC = ({ const { type, geo } = location; const serverTime = getServerTime(); - const isExpired = isGeoLiveExpired(message, serverTime); + const isExpired = isGeoLiveExpired(message); const secondsBeforeEnd = (type === 'geoLive' && !isExpired) ? message.date + location.period - serverTime : undefined; diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index be3d9e321..8da2b99d0 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -35,7 +35,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import { useOnIntersect } from '../../../hooks/useIntersectionObserver'; import type { PinnedIntersectionChangedCallback } from '../hooks/usePinnedMessage'; -import { IS_ANDROID } from '../../../util/windowEnvironment'; +import { IS_ANDROID, IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment'; import { EMOJI_STATUS_LOOP_LIMIT, GENERAL_TOPIC_ID, IS_ELECTRON } from '../../../config'; import { selectAllowedMessageActions, @@ -62,8 +62,10 @@ import { selectOutgoingStatus, selectPerformanceSettingsValue, selectReplySender, - selectRequestedTranslationLanguage, + selectRequestedChatTranslationLanguage, + selectRequestedMessageTranslationLanguage, selectSender, + selectShouldDetectChatLanguage, selectShouldLoopStickers, selectTabState, selectTheme, @@ -90,6 +92,7 @@ import { isChatWithRepliesBot, isGeoLiveExpired, isMessageLocal, + isMessageTranslatable, isOwnMessage, isReplyMessage, isUserId, @@ -105,7 +108,6 @@ import { buildContentClassName } from './helpers/buildContentClassName'; import { calculateMediaDimensions, getMinMediaWidth, MIN_MEDIA_WIDTH_WITH_TEXT } from './helpers/mediaDimensions'; import { calculateAlbumLayout } from './helpers/calculateAlbumLayout'; import renderText from '../../common/helpers/renderText'; -import { getServerTime } from '../../../util/serverTime'; import { isElementInViewport } from '../../../util/isElementInViewport'; import { getCustomEmojiSize } from '../composer/helpers/customEmoji'; import { isAnimatingScroll } from '../../../util/animateScroll'; @@ -127,6 +129,7 @@ import usePrevious from '../../../hooks/usePrevious'; import useTextLanguage from '../../../hooks/useTextLanguage'; import useAuthorWidth from '../hooks/useAuthorWidth'; import { dispatchHeavyAnimationEvent } from '../../../hooks/useHeavyAnimationCheck'; +import useDetectChatLanguage from './hooks/useDetectChatLanguage'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; @@ -251,7 +254,9 @@ type StateProps = { hasTopicChip?: boolean; chatTranslations?: ChatTranslatedMessages; areTranslationsEnabled?: boolean; + shouldDetectChatLanguage?: boolean; requestedTranslationLanguage?: string; + requestedChatTranslationLanguage?: string; withReactionEffects?: boolean; withStickerEffects?: boolean; isConnected: boolean; @@ -362,7 +367,9 @@ const Message: FC = ({ hasTopicChip, chatTranslations, areTranslationsEnabled, + shouldDetectChatLanguage, requestedTranslationLanguage, + requestedChatTranslationLanguage, withReactionEffects, withStickerEffects, isConnected, @@ -552,6 +559,7 @@ const Message: FC = ({ senderPeer, botSender, messageTopic, + Boolean(requestedChatTranslationLanguage), ); useEffect(() => { @@ -597,13 +605,16 @@ const Message: FC = ({ text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game, } = getMessageContent(message); + useDetectChatLanguage(message, !shouldDetectChatLanguage); + const detectedLanguage = useTextLanguage(areTranslationsEnabled ? text?.text : undefined); + const shouldTranslate = isMessageTranslatable(message, !requestedChatTranslationLanguage); const { isPending: isTranslationPending, translatedText } = useMessageTranslation( - chatTranslations, chatId, messageId, requestedTranslationLanguage, + chatTranslations, chatId, shouldTranslate ? messageId : undefined, requestedTranslationLanguage, ); // Used to display previous result while new one is loading - const previousTranslatedText = usePrevious(translatedText, true); + const previousTranslatedText = usePrevious(translatedText, Boolean(shouldTranslate)); const currentTranslatedText = translatedText || previousTranslatedText; @@ -628,7 +639,7 @@ const Message: FC = ({ hasComments: repliesThreadInfo && repliesThreadInfo.messagesCount > 0, hasActionButton: canForward || canFocus, hasReactions, - isGeoLiveActive: location?.type === 'geoLive' && !isGeoLiveExpired(message, getServerTime()), + isGeoLiveActive: location?.type === 'geoLive' && !isGeoLiveExpired(message), withVoiceTranscription, }); @@ -925,6 +936,8 @@ const Message: FC = ({ noUserColors={isOwn || isChannel} isProtected={isProtected} sender={replyMessageSender} + chatTranslations={chatTranslations} + requestedChatTranslationLanguage={requestedChatTranslationLanguage} observeIntersectionForLoading={observeIntersectionForLoading} observeIntersectionForPlaying={observeIntersectionForPlaying} onClick={handleReplyClick} @@ -1441,7 +1454,12 @@ export default memo(withGlobal( const isLocation = Boolean(getMessageLocation(message)); const chatTranslations = selectChatTranslations(global, chatId); - const requestedTranslationLanguage = selectRequestedTranslationLanguage(global, chatId, message.id); + + const requestedTranslationLanguage = selectRequestedMessageTranslationLanguage(global, chatId, message.id); + const requestedChatTranslationLanguage = selectRequestedChatTranslationLanguage(global, chatId); + + const areTranslationsEnabled = IS_TRANSLATION_SUPPORTED && global.settings.byKey.canTranslate + && !requestedChatTranslationLanguage; // Stop separate language detection if chat translation is requested const isConnected = global.connectionState === 'connectionStateReady'; @@ -1500,8 +1518,10 @@ export default memo(withGlobal( genericEffects: global.genericEmojiEffects, hasTopicChip, chatTranslations, - areTranslationsEnabled: global.settings.byKey.canTranslate, + areTranslationsEnabled, + shouldDetectChatLanguage: selectShouldDetectChatLanguage(global, chatId), requestedTranslationLanguage, + requestedChatTranslationLanguage, hasLinkedChat: Boolean(chatFullInfo?.linkedChatId), withReactionEffects: selectPerformanceSettingsValue(global, 'reactionEffects'), withStickerEffects: selectPerformanceSettingsValue(global, 'stickerEffects'), diff --git a/src/components/middle/message/hooks/useDetectChatLanguage.ts b/src/components/middle/message/hooks/useDetectChatLanguage.ts new file mode 100644 index 000000000..a4b6736f0 --- /dev/null +++ b/src/components/middle/message/hooks/useDetectChatLanguage.ts @@ -0,0 +1,104 @@ +import type { ApiMessage } from '../../../../api/types'; +import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../../config'; +import { getActions } from '../../../../global'; +import useTextLanguage from '../../../../hooks/useTextLanguage'; +import LimitedMap from '../../../../util/primitives/LimitedMap'; +import { throttle } from '../../../../util/schedulers'; + +// https://github.com/DrKLO/Telegram/blob/dfd74f809e97d1ecad9672fc7388cb0223a95dfc/TMessagesProj/src/main/java/org/telegram/messenger/TranslateController.java#L35 +const MIN_MESSAGES_CHECKED = 8; +const MIN_TRANSLATABLE_RATIO = 0.3; +const MIN_DETECTABLE_RATIO = 0.6; + +const THROTTLE_DELAY = 1000; +const MESSAGES_LIMIT = 150; + +type MessageMetadata = { + id: number; + isTranslatable: boolean; + detectedLanguage: string | undefined; +}; + +const CHAT_STATS = new Map>(); + +export default function useDetectChatLanguage(message: ApiMessage, isDisabled?: boolean) { + const canProcess = !isDisabled && message.chatId !== SERVICE_NOTIFICATIONS_USER_ID; + + const isTranslatable = Boolean(message.content.text?.text.length); + const detectedLanguage = useTextLanguage(message.content.text?.text, !canProcess); + + if (!canProcess) return; + + processMessageMetadata(message.chatId, message.id, isTranslatable, detectedLanguage); +} + +const throttledMakeChatDecision = throttle(makeChatDecision, THROTTLE_DELAY); + +function processMessageMetadata(chatId: string, id: number, isTranslatable: boolean, detectedLanguage?: string) { + const chatStats = CHAT_STATS.get(chatId) || new LimitedMap(MESSAGES_LIMIT); + + const previousMetadata = chatStats.get(id); + if (previousMetadata && previousMetadata.detectedLanguage === detectedLanguage + && previousMetadata.isTranslatable === isTranslatable + ) { + return; + } + + chatStats.set(id, { + id, + isTranslatable, + detectedLanguage, + }); + + CHAT_STATS.set(chatId, chatStats); + + throttledMakeChatDecision(chatId); +} + +function makeChatDecision(chatId: string) { + const { updateChatDetectedLanguage } = getActions(); + const chatStats = CHAT_STATS.get(chatId); + if (!chatStats) { + return; + } + + const messagesChecked = chatStats.size; + if (messagesChecked < MIN_MESSAGES_CHECKED) { + return; + } + + let translatableCount = 0; + let detectableCount = 0; + const languageOccurrences = new Map(); + + for (const metadata of chatStats.values()) { + if (metadata.isTranslatable) { + translatableCount++; + } + + if (metadata.detectedLanguage) { + detectableCount++; + } + + const language = metadata.detectedLanguage; + if (language) { + const occurrences = languageOccurrences.get(language) || 0; + languageOccurrences.set(language, occurrences + 1); + } + } + + const translatableRatio = translatableCount / messagesChecked; + const detectableRatio = detectableCount / messagesChecked; + + if (translatableRatio < MIN_TRANSLATABLE_RATIO || detectableRatio < MIN_DETECTABLE_RATIO) { + return; + } + + const mostFrequentLanguage = Array.from(languageOccurrences.entries()) + .sort(([, a], [, b]) => b - a)[0][0]; + + updateChatDetectedLanguage({ + chatId, + detectedLanguage: mostFrequentLanguage, + }); +} diff --git a/src/components/middle/message/hooks/useInnerHandlers.ts b/src/components/middle/message/hooks/useInnerHandlers.ts index 55d2fac89..be5605f5f 100644 --- a/src/components/middle/message/hooks/useInnerHandlers.ts +++ b/src/components/middle/message/hooks/useInnerHandlers.ts @@ -26,11 +26,12 @@ export default function useInnerHandlers( senderPeer?: ApiUser | ApiChat, botSender?: ApiUser, messageTopic?: ApiTopic, + isTranslatingChat?: boolean, ) { const { openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer, markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, focusMessageInComments, - openMessageLanguageModal, + openChatLanguageModal, } = getActions(); const { @@ -160,7 +161,7 @@ export default function useInnerHandlers( const handleTranslationClick = useLastCallback((e: React.MouseEvent) => { e.stopPropagation(); - openMessageLanguageModal({ chatId, id: messageId }); + openChatLanguageModal({ chatId, messageId: !isTranslatingChat ? messageId : undefined }); }); const handleOpenThread = useLastCallback(() => { diff --git a/src/components/middle/message/hooks/useMessageTranslation.ts b/src/components/middle/message/hooks/useMessageTranslation.ts index 61d2b3604..b77b8be21 100644 --- a/src/components/middle/message/hooks/useMessageTranslation.ts +++ b/src/components/middle/message/hooks/useMessageTranslation.ts @@ -1,27 +1,116 @@ import { useEffect } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; import type { ChatTranslatedMessages } from '../../../../global/types'; +import { throttle } from '../../../../util/schedulers'; + +const MESSAGE_LIMIT_PER_REQUEST = 20; +const THROTTLE_DELAY = 500; +const PENDING_TRANSLATIONS = new Map>(); export default function useMessageTranslation( chatTranslations: ChatTranslatedMessages | undefined, - chatId: string, - messageId: number, + chatId?: string, + messageId?: number, requestedLanguageCode?: string, ) { - const { translateMessages } = getActions(); - const messageTranslation = requestedLanguageCode + const messageTranslation = requestedLanguageCode && messageId ? chatTranslations?.byLangCode[requestedLanguageCode]?.[messageId] : undefined; const { isPending, text } = messageTranslation || {}; useEffect(() => { - if (!text && !isPending && requestedLanguageCode) { - translateMessages({ chatId, messageIds: [messageId], toLanguageCode: requestedLanguageCode }); + if (!chatId || !messageId) return; + + if (!text && isPending === undefined && requestedLanguageCode) { + addPendingTranslation(chatId, messageId, requestedLanguageCode); } - }, [chatId, text, isPending, messageId, requestedLanguageCode, translateMessages]); + }, [chatId, text, isPending, messageId, requestedLanguageCode]); + + if (!chatId || !messageId) { + return { + isPending: false, + translatedText: undefined, + }; + } return { isPending, translatedText: text, }; } + +const throttledProcessPending = throttle(processPending, THROTTLE_DELAY); + +function processPending() { + const { translateMessages } = getActions(); + let hasUnprocessed = false; + PENDING_TRANSLATIONS.forEach((chats, toLanguageCode) => { + chats.forEach((messageIds, chatId) => { + const messageIdsToTranslate = messageIds.slice(0, MESSAGE_LIMIT_PER_REQUEST); + + if (messageIdsToTranslate.length < messageIds.length) { + hasUnprocessed = true; + } + + translateMessages({ chatId, messageIds: messageIdsToTranslate, toLanguageCode }); + + removePendingTranslations(chatId, messageIdsToTranslate, toLanguageCode); + }); + }); + + if (hasUnprocessed) { + throttledProcessPending(); + } +} + +function addPendingTranslation( + chatId: string, + messageId: number, + toLanguageCode: string, +) { + const languageTranslations = PENDING_TRANSLATIONS.get(toLanguageCode) || new Map(); + const messageIds = languageTranslations.get(chatId) || []; + + if (messageIds.includes(messageId)) { + throttledProcessPending(); + return; + } + + messageIds.push(messageId); + languageTranslations.set(chatId, messageIds); + PENDING_TRANSLATIONS.set(toLanguageCode, languageTranslations); + + getActions().markMessagesTranslationPending({ chatId, messageIds, toLanguageCode }); + + throttledProcessPending(); +} + +function removePendingTranslations( + chatId: string, + messageIds: number[], + toLanguageCode: string, +) { + const languageTranslations = PENDING_TRANSLATIONS.get(toLanguageCode); + if (!languageTranslations?.size) { + PENDING_TRANSLATIONS.delete(toLanguageCode); + return; + } + + const oldMessageIds = languageTranslations.get(chatId); + if (!oldMessageIds?.length) { + languageTranslations.delete(chatId); + return; + } + + const newMessageIds = oldMessageIds.filter((id) => !messageIds.includes(id)); + + if (!newMessageIds?.length) { + languageTranslations.delete(chatId); + if (!languageTranslations.size) { + PENDING_TRANSLATIONS.delete(toLanguageCode); + } + return; + } + + languageTranslations.set(chatId, newMessageIds); +} diff --git a/src/components/ui/Checkbox.scss b/src/components/ui/Checkbox.scss index 8aa6db79e..0c9fc73ba 100644 --- a/src/components/ui/Checkbox.scss +++ b/src/components/ui/Checkbox.scss @@ -8,7 +8,7 @@ cursor: var(--custom-cursor, pointer); &.disabled { - pointer-events: none; + cursor: var(--custom-cursor, default); opacity: 0.5; } diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index b57791eed..fa85041ff 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -54,6 +54,10 @@ const Checkbox: FC = ({ const labelRef = useRef(null); const handleChange = useCallback((event: ChangeEvent) => { + if (disabled) { + return; + } + if (onChange) { onChange(event); } @@ -61,7 +65,7 @@ const Checkbox: FC = ({ if (onCheck) { onCheck(event.currentTarget.checked); } - }, [onChange, onCheck]); + }, [disabled, onChange, onCheck]); function handleClick(event: React.MouseEvent) { if (event.target !== labelRef.current) { diff --git a/src/global/actions/api/chats.ts b/src/global/actions/api/chats.ts index 492863789..a360163b4 100644 --- a/src/global/actions/api/chats.ts +++ b/src/global/actions/api/chats.ts @@ -56,6 +56,7 @@ import { updateListedTopicIds, updateChatFullInfo, replaceChatFullInfo, + updateUserFullInfo, } from '../../reducers'; import { selectChat, selectUser, selectChatListType, selectIsChatPinned, @@ -73,6 +74,7 @@ import { isChatChannel, isChatSuperGroup, isUserBot, + isUserId, } from '../../helpers'; import { formatShareText, parseChooseParameter, processDeepLink } from '../../../util/deeplink'; import { updateGroupCall } from '../../reducers/calls'; @@ -2158,6 +2160,39 @@ addActionHandler('openDeleteChatFolderModal', async (global, actions, payload): setGlobal(global); }); +addActionHandler('updateChatDetectedLanguage', (global, actions, payload): ActionReturnType => { + const { chatId, detectedLanguage } = payload; + + global = getGlobal(); + global = updateChat(global, chatId, { + detectedLanguage, + }); + + return global; +}); + +addActionHandler('togglePeerTranslations', async (global, actions, payload): Promise => { + const { chatId, isEnabled } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('togglePeerTranslations', { chat, isEnabled }); + + if (result === undefined) return; + + global = getGlobal(); + if (isUserId(chatId)) { + global = updateUserFullInfo(global, chatId, { + isTranslationDisabled: isEnabled ? undefined : true, + }); + } else { + global = updateChatFullInfo(global, chatId, { + isTranslationDisabled: isEnabled ? undefined : true, + }); + } + setGlobal(global); +}); + async function loadChats( listType: 'active' | 'archived', offsetId?: string, diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 5dfd61708..d43006a55 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -44,6 +44,7 @@ import { removeOutlyingList, removeRequestedMessageTranslation, replaceScheduledMessages, + replaceSettings, replaceThreadParam, safeReplacePinnedIds, safeReplaceViewportIds, @@ -63,6 +64,7 @@ import { import { selectChat, selectChatMessage, + selectTranslationLanguage, selectCurrentChat, selectCurrentMessageList, selectDraft, @@ -1493,10 +1495,13 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet addActionHandler('requestMessageTranslation', (global, actions, payload): ActionReturnType => { const { - chatId, id, toLanguageCode = selectLanguageCode(global), tabId = getCurrentTabId(), + chatId, id, toLanguageCode = selectTranslationLanguage(global), tabId = getCurrentTabId(), } = payload; global = updateRequestedMessageTranslation(global, chatId, id, toLanguageCode, tabId); + global = replaceSettings(global, { + translationLanguage: toLanguageCode, + }); return global; }); @@ -1511,6 +1516,20 @@ addActionHandler('showOriginalMessage', (global, actions, payload): ActionReturn return global; }); +addActionHandler('markMessagesTranslationPending', (global, actions, payload): ActionReturnType => { + const { + chatId, messageIds, toLanguageCode = selectLanguageCode(global), + } = payload; + + messageIds.forEach((id) => { + global = updateMessageTranslation(global, chatId, id, toLanguageCode, { + isPending: true, + }); + }); + + return global; +}); + addActionHandler('translateMessages', (global, actions, payload): ActionReturnType => { const { chatId, messageIds, toLanguageCode = selectLanguageCode(global), @@ -1519,11 +1538,7 @@ addActionHandler('translateMessages', (global, actions, payload): ActionReturnTy const chat = selectChat(global, chatId); if (!chat) return undefined; - messageIds.forEach((id) => { - global = updateMessageTranslation(global, chatId, id, toLanguageCode, { - isPending: true, - }); - }); + actions.markMessagesTranslationPending({ chatId, messageIds, toLanguageCode }); callApi('translateText', { chat, diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index bc6bb80aa..e0c18f273 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -4,7 +4,7 @@ import { IS_ELECTRON } from '../../../config'; import { MAIN_THREAD_ID } from '../../../api/types'; import { - exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList, + exitMessageSelectMode, replaceTabThreadParam, updateCurrentMessageList, updateRequestedChatTranslation, } from '../../reducers'; import { selectChat, selectCurrentMessageList, selectTabState, @@ -175,3 +175,8 @@ addActionHandler('closeChatlistModal', (global, actions, payload): ActionReturnT chatlistModal: undefined, }, tabId); }); + +addActionHandler('requestChatTranslation', (global, actions, payload): ActionReturnType => { + const { chatId, toLanguageCode, tabId = getCurrentTabId() } = payload; + return updateRequestedChatTranslation(global, chatId, toLanguageCode, tabId); +}); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 9af8b63d7..d4ac8deb1 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -42,8 +42,9 @@ import { selectSender, selectChatScheduledMessages, selectTabState, - selectRequestedTranslationLanguage, + selectRequestedMessageTranslationLanguage, selectPinnedIds, + selectRequestedChatTranslationLanguage, } from '../../selectors'; import { compact, findLast } from '../../../util/iteratees'; import { getServerTime } from '../../../util/serverTime'; @@ -771,21 +772,23 @@ addActionHandler('closeSeenByModal', (global, actions, payload): ActionReturnTyp }, tabId); }); -addActionHandler('openMessageLanguageModal', (global, actions, payload): ActionReturnType => { - const { chatId, id, tabId = getCurrentTabId() } = payload; +addActionHandler('openChatLanguageModal', (global, actions, payload): ActionReturnType => { + const { chatId, messageId, tabId = getCurrentTabId() } = payload; - const activeLanguage = selectRequestedTranslationLanguage(global, chatId, id, tabId); + const activeLanguage = messageId + ? selectRequestedMessageTranslationLanguage(global, chatId, messageId, tabId) + : selectRequestedChatTranslationLanguage(global, chatId, tabId); return updateTabState(global, { - messageLanguageModal: { chatId, messageId: id, activeLanguage }, + chatLanguageModal: { chatId, messageId, activeLanguage }, }, tabId); }); -addActionHandler('closeMessageLanguageModal', (global, actions, payload): ActionReturnType => { +addActionHandler('closeChatLanguageModal', (global, actions, payload): ActionReturnType => { const { tabId = getCurrentTabId() } = payload || {}; return updateTabState(global, { - messageLanguageModal: undefined, + chatLanguageModal: undefined, }, tabId); }); diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index 10bb27885..7e0931fc3 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -14,6 +14,7 @@ import { IS_OPUS_SUPPORTED, isWebpSupported } from '../../util/windowEnvironment import { getChatTitle, isUserId } from './chats'; import { getGlobal } from '../index'; import { areSortedArraysIntersecting, unique } from '../../util/iteratees'; +import { getServerTime } from '../../util/serverTime'; const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); @@ -246,10 +247,21 @@ export function getMessageContentFilename(message: ApiMessage) { return baseFilename; } -export function isGeoLiveExpired(message: ApiMessage, timestamp = Date.now() / 1000) { +export function isGeoLiveExpired(message: ApiMessage) { const { location } = message.content; if (location?.type !== 'geoLive') return false; - return (timestamp - (message.date || 0) >= location.period); + return getServerTime() - (message.date || 0) >= location.period; +} + +export function isMessageTranslatable(message: ApiMessage, allowOutgoing?: boolean) { + const { text, game } = message.content; + + const isLocal = isMessageLocal(message); + const isServiceNotification = isServiceNotificationMessage(message); + const isAction = isActionMessage(message); + + return Boolean(text?.text.length && !message.emojiOnlyCount && !game && (allowOutgoing || !message.isOutgoing) + && !isLocal && !isServiceNotification && !isAction && !message.isScheduled); } export function getMessageSingleInlineButton(message: ApiMessage) { diff --git a/src/global/initialState.ts b/src/global/initialState.ts index 2a82b9689..c3880be6a 100644 --- a/src/global/initialState.ts +++ b/src/global/initialState.ts @@ -221,6 +221,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = { isConnectionStatusMinimized: true, shouldArchiveAndMuteNewNonContact: false, canTranslate: false, + canTranslateChats: true, doNotTranslate: [], canDisplayChatInTitle: true, shouldAllowHttpTransport: true, diff --git a/src/global/reducers/translations.ts b/src/global/reducers/translations.ts index 035d6e85e..efb4c4309 100644 --- a/src/global/reducers/translations.ts +++ b/src/global/reducers/translations.ts @@ -79,6 +79,40 @@ export function updateMessageTranslations( return global; } +export function updateRequestedChatTranslation( + global: T, chatId: string, toLanguageCode?: string, ...[tabId = getCurrentTabId()]: TabArgs +) { + const tabState = selectTabState(global, tabId); + global = updateTabState(global, { + requestedTranslations: { + ...tabState.requestedTranslations, + byChatId: { + ...tabState.requestedTranslations.byChatId, + [chatId]: { + toLanguage: toLanguageCode, + }, + }, + }, + }, tabId); + + return global; +} + +export function removeRequestedChatTranslation( + global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs +) { + const tabState = selectTabState(global, tabId); + + global = updateTabState(global, { + requestedTranslations: { + ...tabState.requestedTranslations, + byChatId: omit(tabState.requestedTranslations.byChatId, [chatId]), + }, + }, tabId); + + return global; +} + export function updateRequestedMessageTranslation( global: T, chatId: string, messageId: number, toLanguageCode: string, ...[tabId = getCurrentTabId()]: TabArgs ) { diff --git a/src/global/selectors/chats.ts b/src/global/selectors/chats.ts index 0cf581e9f..31ab62bf1 100644 --- a/src/global/selectors/chats.ts +++ b/src/global/selectors/chats.ts @@ -12,12 +12,15 @@ import { getHasAdminRight, isChatSuperGroup, } from '../helpers'; -import { selectBot, selectUser } from './users'; +import { + selectBot, selectIsCurrentUserPremium, selectUser, selectUserFullInfo, +} from './users'; import { ALL_FOLDER_ID, ARCHIVED_FOLDER_ID, MEMBERS_LOAD_SLICE, SERVICE_NOTIFICATIONS_USER_ID, } from '../../config'; import { selectTabState } from './tabs'; import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment'; export function selectChat(global: T, chatId: string): ApiChat | undefined { return global.chats.byId[chatId]; @@ -61,7 +64,7 @@ export function selectChatOnlineCount(global: T, chat: Ap return fullInfo.members.reduce((onlineCount, { userId }) => { if ( - userId !== global.currentUserId + !selectIsChatWithSelf(global, userId) && global.users.byId[userId] && isUserOnline(global.users.byId[userId], global.users.statusesById[userId]) ) { @@ -263,3 +266,43 @@ export function selectCanShareFolder(global: T, folderId: return selectCanInviteToChat(global, chatId); }); } + +export function selectShouldDetectChatLanguage( + global: T, chatId: string, +) { + const chat = selectChat(global, chatId); + const fullInfo = isUserId(chatId) ? selectUserFullInfo(global, chatId) : selectChatFullInfo(global, chatId); + if (!chat || !fullInfo) return false; + const { canTranslateChats } = global.settings.byKey; + + const isPremium = selectIsCurrentUserPremium(global); + const isSavedMessages = selectIsChatWithSelf(global, chatId); + + return IS_TRANSLATION_SUPPORTED && canTranslateChats && isPremium && !isSavedMessages; +} + +export function selectCanTranslateChat( + global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs +) { + const chat = selectChat(global, chatId); + if (!chat) return false; + + const requestedTranslation = selectRequestedChatTranslationLanguage(global, chatId, tabId); + if (requestedTranslation) return true; // Prevent translation dropping on reevaluation + + const isLanguageDetectable = selectShouldDetectChatLanguage(global, chatId); + const detectedLanguage = chat.detectedLanguage; + + const { doNotTranslate } = global.settings.byKey; + + return Boolean(isLanguageDetectable && detectedLanguage && !doNotTranslate.includes(detectedLanguage)); +} + +export function selectRequestedChatTranslationLanguage( + global: T, chatId: string, + ...[tabId = getCurrentTabId()]: TabArgs +) { + const { requestedTranslations } = selectTabState(global, tabId); + + return requestedTranslations.byChatId[chatId]?.toLanguage; +} diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index 9676fea95..afc789cd1 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -15,7 +15,7 @@ import { GENERAL_TOPIC_ID, REPLIES_USER_ID, SERVICE_NOTIFICATIONS_USER_ID, } from '../../config'; import { - selectChat, selectChatFullInfo, selectIsChatWithSelf, + selectChat, selectChatFullInfo, selectIsChatWithSelf, selectRequestedChatTranslationLanguage, } from './chats'; import { selectBot, @@ -50,7 +50,7 @@ import { isUserRightBanned, canSendReaction, getAllowedAttachmentOptions, - isLocalMessageId, isMessageFailed, + isLocalMessageId, isMessageFailed, isMessageTranslatable, } from '../helpers'; import { findLast } from '../../util/iteratees'; import { selectIsStickerFavorite } from './symbols'; @@ -58,6 +58,7 @@ import { getServerTime } from '../../util/serverTime'; import { MEMO_EMPTY_ARRAY } from '../../util/memo'; import { selectTabState } from './tabs'; import { getCurrentTabId } from '../../util/establishMultitabRole'; +import { IS_TRANSLATION_SUPPORTED } from '../../util/windowEnvironment'; const MESSAGE_EDIT_ALLOWED_TIME = 172800; // 48 hours @@ -1271,10 +1272,11 @@ export function selectMessageTranslations( return selectChatTranslations(global, chatId)?.byLangCode[toLanguageCode] || {}; } -export function selectRequestedTranslationLanguage( - global: T, chatId: string, messageId: number, tabId = getCurrentTabId(), +export function selectRequestedMessageTranslationLanguage( + global: T, chatId: string, messageId: number, ...[tabId = getCurrentTabId()]: TabArgs ): string | undefined { - return selectTabState(global, tabId).requestedTranslations.byChatId[chatId]?.manualMessages?.[messageId]; + const requestedInChat = selectTabState(global, tabId).requestedTranslations.byChatId[chatId]; + return requestedInChat?.toLanguage || requestedInChat?.manualMessages?.[messageId]; } export function selectForwardsCanBeSentToChat( @@ -1315,3 +1317,19 @@ export function selectForwardsCanBeSentToChat( || (isPlainText && !canSendPlainText); }); } + +export function selectCanTranslateMessage( + global: T, message: ApiMessage, detectedLanguage?: string, ...[tabId = getCurrentTabId()]: TabArgs +) { + const { canTranslate: isTranslationEnabled, doNotTranslate } = global.settings.byKey; + + const canTranslateLanguage = !detectedLanguage || !doNotTranslate.includes(detectedLanguage); + + const isTranslatable = isMessageTranslatable(message); + + // Separate translations are disabled when chat translation enabled + const chatRequestedLanguage = selectRequestedChatTranslationLanguage(global, message.chatId, tabId); + + return IS_TRANSLATION_SUPPORTED && isTranslationEnabled && canTranslateLanguage && isTranslatable + && !chatRequestedLanguage; +} diff --git a/src/global/selectors/settings.ts b/src/global/selectors/settings.ts index 8bd043da8..0ea135cd4 100644 --- a/src/global/selectors/settings.ts +++ b/src/global/selectors/settings.ts @@ -15,3 +15,7 @@ export function selectLanguageCode(global: T) { export function selectCanSetPasscode(global: T) { return global.authRememberMe && global.isCacheApiSupported; } + +export function selectTranslationLanguage(global: T) { + return global.settings.byKey.translationLanguage || selectLanguageCode(global); +} diff --git a/src/global/types.ts b/src/global/types.ts index 61bda3dd1..d09d3435f 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -564,9 +564,9 @@ export type TabState = { requestedTranslations: { byChatId: Record; }; - messageLanguageModal?: { + chatLanguageModal?: { chatId: string; - messageId: number; + messageId?: number; activeLanguage?: string; }; @@ -1445,11 +1445,11 @@ export interface ActionPayloads { disableContextMenuHint: undefined; focusNextReply: WithTabId | undefined; - openMessageLanguageModal: { + openChatLanguageModal: { chatId: string; - id: number; + messageId?: number; } & WithTabId; - closeMessageLanguageModal: WithTabId | undefined; + closeChatLanguageModal: WithTabId | undefined; // poll result openPollResults: { @@ -1640,6 +1640,10 @@ export interface ActionPayloads { about: string; photo?: File; } & WithTabId; + updateChatDetectedLanguage: { + chatId: string; + detectedLanguage?: string; + }; toggleSignatures: { chatId: string; isEnabled: boolean; @@ -1734,6 +1738,16 @@ export interface ActionPayloads { url: string; } & WithTabId; + requestChatTranslation: { + chatId: string; + toLanguageCode?: string; + } & WithTabId; + + togglePeerTranslations: { + chatId: string; + isEnabled: boolean; + }; + // Messages setEditingDraft: { text?: ApiFormattedText; @@ -1799,6 +1813,11 @@ export interface ActionPayloads { id: number; } & WithTabId; + markMessagesTranslationPending: { + chatId: string; + messageIds: number[]; + toLanguageCode?: string; + }; translateMessages: { chatId: string; messageIds: number[]; diff --git a/src/hooks/useTextLanguage.ts b/src/hooks/useTextLanguage.ts index e999bbb5e..998bb6e6a 100644 --- a/src/hooks/useTextLanguage.ts +++ b/src/hooks/useTextLanguage.ts @@ -4,14 +4,16 @@ import { detectLanguage } from '../util/languageDetection'; import useSyncEffect from './useSyncEffect'; -export default function useTextLanguage(text?: string) { - const [language, setLanguage] = useState(); +export default function useTextLanguage(text?: string, isDisabled?: boolean) { + const [language, setLanguage] = useState(); useSyncEffect(() => { - if (text) { + if (text && !isDisabled) { detectLanguage(text).then(setLanguage); + } else { + setLanguage(undefined); } - }, [text]); + }, [isDisabled, text]); return language; } diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 3be8589c0..5827d747d 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1305,6 +1305,7 @@ messages.getTopReactions#bb8125ba limit:int hash:long = messages.Reactions; messages.getRecentReactions#39461db2 limit:int hash:long = messages.Reactions; messages.clearRecentReactions#9dfeefb4 = Bool; messages.getExtendedMedia#84f80814 peer:InputPeer id:Vector = Updates; +messages.togglePeerTranslations#e47cb579 flags:# disabled:flags.0?true peer:InputPeer = Bool; messages.getBotApp#34fdc5c3 app:InputBotApp hash:long = messages.BotApp; messages.requestAppWebView#8c5a3b3c flags:# write_allowed:flags.0?true peer:InputPeer app:InputBotApp start_param:flags.1?string theme_params:flags.2?DataJSON platform:string = AppWebViewResult; updates.getState#edd4882a = updates.State; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 1d62e3251..3e203d216 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -166,6 +166,7 @@ "messages.getExtendedMedia", "messages.getBotApp", "messages.requestAppWebView", + "messages.togglePeerTranslations", "updates.getState", "updates.getDifference", "updates.getChannelDifference", diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index b8fcf86aa..91fc3ccf0 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1686651595326 + "created": 1688021234757 }, "iconSets": [ { @@ -157,13 +157,37 @@ }, { "selection": [ + { + "order": 766, + "id": 97, + "name": "hand-stop", + "prevSize": 32, + "code": 59843, + "tempChar": "" + }, + { + "order": 765, + "id": 96, + "name": "more-circle", + "prevSize": 32, + "code": 59844, + "tempChar": "" + }, + { + "order": 764, + "id": 95, + "name": "close-circle", + "prevSize": 32, + "code": 59845, + "tempChar": "" + }, { "order": 763, "id": 94, "name": "settings-filled", "prevSize": 32, "code": 59841, - "tempChar": "" + "tempChar": "" }, { "order": 762, @@ -171,7 +195,7 @@ "name": "share-screen-stop", "prevSize": 32, "code": 59842, - "tempChar": "" + "tempChar": "" }, { "order": 761, @@ -179,7 +203,7 @@ "name": "user-online", "prevSize": 32, "code": 59840, - "tempChar": "" + "tempChar": "" }, { "order": 760, @@ -187,7 +211,7 @@ "name": "pinned-message", "prevSize": 32, "code": 59839, - "tempChar": "" + "tempChar": "" }, { "order": 759, @@ -195,7 +219,7 @@ "name": "archive-filled", "prevSize": 32, "code": 59834, - "tempChar": "" + "tempChar": "" }, { "order": 758, @@ -203,7 +227,7 @@ "name": "archive-from-main", "prevSize": 32, "code": 59835, - "tempChar": "" + "tempChar": "" }, { "order": 757, @@ -211,7 +235,7 @@ "name": "archive-to-main", "prevSize": 32, "code": 59836, - "tempChar": "" + "tempChar": "" }, { "order": 756, @@ -219,7 +243,7 @@ "name": "collapse", "prevSize": 32, "code": 59837, - "tempChar": "" + "tempChar": "" }, { "order": 755, @@ -227,7 +251,7 @@ "name": "expand", "prevSize": 32, "code": 59838, - "tempChar": "" + "tempChar": "" }, { "order": 754, @@ -235,7 +259,7 @@ "name": "replies", "prevSize": 32, "code": 59833, - "tempChar": "" + "tempChar": "" }, { "order": 746, @@ -243,7 +267,7 @@ "name": "forums", "prevSize": 32, "code": 59828, - "tempChar": "" + "tempChar": "" }, { "order": 743, @@ -251,7 +275,7 @@ "name": "hashtag", "prevSize": 32, "code": 59825, - "tempChar": "" + "tempChar": "" }, { "order": 744, @@ -259,7 +283,7 @@ "name": "reopen-topic", "prevSize": 32, "code": 59826, - "tempChar": "" + "tempChar": "" }, { "order": 745, @@ -267,7 +291,7 @@ "name": "close-topic", "prevSize": 32, "code": 59827, - "tempChar": "" + "tempChar": "" }, { "order": 739, @@ -275,7 +299,7 @@ "name": "open-in-new-tab", "prevSize": 32, "code": 59823, - "tempChar": "" + "tempChar": "" }, { "order": 738, @@ -283,7 +307,7 @@ "name": "pip", "prevSize": 32, "code": 59822, - "tempChar": "" + "tempChar": "" }, { "order": 737, @@ -291,7 +315,7 @@ "name": "gift", "prevSize": 32, "code": 59821, - "tempChar": "" + "tempChar": "" }, { "order": 734, @@ -299,7 +323,7 @@ "name": "sort", "prevSize": 32, "code": 59820, - "tempChar": "" + "tempChar": "" }, { "order": 732, @@ -307,7 +331,7 @@ "name": "web", "prevSize": 32, "code": 59819, - "tempChar": "" + "tempChar": "" }, { "order": 731, @@ -315,7 +339,7 @@ "name": "transcribe", "prevSize": 32, "code": 59818, - "tempChar": "" + "tempChar": "" }, { "order": 719, @@ -323,7 +347,7 @@ "name": "add-one-badge", "prevSize": 32, "code": 59803, - "tempChar": "" + "tempChar": "" }, { "order": 720, @@ -331,7 +355,7 @@ "name": "chat-badge", "prevSize": 32, "code": 59808, - "tempChar": "" + "tempChar": "" }, { "order": 721, @@ -339,7 +363,7 @@ "name": "chats-badge", "prevSize": 32, "code": 59809, - "tempChar": "" + "tempChar": "" }, { "order": 722, @@ -347,7 +371,7 @@ "name": "double-badge", "prevSize": 32, "code": 59810, - "tempChar": "" + "tempChar": "" }, { "order": 723, @@ -355,7 +379,7 @@ "name": "file-badge", "prevSize": 32, "code": 59811, - "tempChar": "" + "tempChar": "" }, { "order": 724, @@ -363,7 +387,7 @@ "name": "folder-badge", "prevSize": 32, "code": 59812, - "tempChar": "" + "tempChar": "" }, { "order": 726, @@ -371,7 +395,7 @@ "name": "link-badge", "prevSize": 32, "code": 59813, - "tempChar": "" + "tempChar": "" }, { "order": 725, @@ -379,7 +403,7 @@ "name": "pin-badge", "prevSize": 32, "code": 59814, - "tempChar": "" + "tempChar": "" }, { "order": 727, @@ -387,7 +411,7 @@ "name": "premium", "prevSize": 32, "code": 59815, - "tempChar": "" + "tempChar": "" }, { "order": 728, @@ -395,7 +419,7 @@ "name": "unlock-badge", "prevSize": 32, "code": 59816, - "tempChar": "" + "tempChar": "" }, { "order": 729, @@ -403,7 +427,7 @@ "name": "lock-badge", "prevSize": 32, "code": 59817, - "tempChar": "" + "tempChar": "" }, { "order": 715, @@ -411,7 +435,7 @@ "name": "key", "prevSize": 32, "code": 59802, - "tempChar": "" + "tempChar": "" }, { "order": 714, @@ -419,7 +443,7 @@ "name": "heart-outline", "prevSize": 32, "code": 59806, - "tempChar": "" + "tempChar": "" }, { "order": 713, @@ -427,7 +451,7 @@ "name": "heart", "prevSize": 32, "code": 59807, - "tempChar": "" + "tempChar": "" }, { "order": 712, @@ -435,7 +459,7 @@ "name": "word-wrap", "prevSize": 32, "code": 59805, - "tempChar": "" + "tempChar": "" }, { "order": 708, @@ -443,7 +467,7 @@ "name": "webapp", "prevSize": 32, "code": 59795, - "tempChar": "" + "tempChar": "" }, { "order": 707, @@ -451,7 +475,7 @@ "name": "reload", "prevSize": 32, "code": 59796, - "tempChar": "" + "tempChar": "" }, { "order": 706, @@ -459,7 +483,7 @@ "name": "install", "prevSize": 32, "code": 59801, - "tempChar": "" + "tempChar": "" }, { "order": 705, @@ -467,7 +491,7 @@ "name": "favorite-filled", "prevSize": 32, "code": 59800, - "tempChar": "" + "tempChar": "" }, { "order": 702, @@ -475,7 +499,7 @@ "name": "share-screen", "prevSize": 32, "code": 59770, - "tempChar": "" + "tempChar": "" }, { "order": 701, @@ -483,7 +507,7 @@ "name": "video-outlined", "prevSize": 32, "code": 59799, - "tempChar": "" + "tempChar": "" }, { "order": 700, @@ -491,7 +515,7 @@ "name": "stats", "prevSize": 32, "code": 59798, - "tempChar": "" + "tempChar": "" }, { "order": 699, @@ -499,7 +523,7 @@ "name": "copy-media", "prevSize": 32, "code": 59797, - "tempChar": "" + "tempChar": "" }, { "order": 704, @@ -507,7 +531,7 @@ "name": "sidebar", "prevSize": 32, "code": 59794, - "tempChar": "" + "tempChar": "" }, { "order": 690, @@ -515,7 +539,7 @@ "name": "video-stop", "prevSize": 32, "code": 59787, - "tempChar": "" + "tempChar": "" }, { "order": 678, @@ -523,7 +547,7 @@ "name": "speaker", "prevSize": 32, "code": 59777, - "tempChar": "" + "tempChar": "" }, { "order": 679, @@ -531,7 +555,7 @@ "name": "speaker-outline", "prevSize": 32, "code": 59778, - "tempChar": "" + "tempChar": "" }, { "order": 680, @@ -539,7 +563,7 @@ "name": "phone-discard-outline", "prevSize": 32, "code": 59779, - "tempChar": "" + "tempChar": "" }, { "order": 681, @@ -547,7 +571,7 @@ "name": "allow-speak", "prevSize": 32, "code": 59780, - "tempChar": "" + "tempChar": "" }, { "order": 682, @@ -555,7 +579,7 @@ "name": "stop-raising-hand", "prevSize": 32, "code": 59781, - "tempChar": "" + "tempChar": "" }, { "order": 683, @@ -563,7 +587,7 @@ "name": "share-screen-outlined", "prevSize": 32, "code": 59782, - "tempChar": "" + "tempChar": "" }, { "order": 684, @@ -571,7 +595,7 @@ "name": "voice-chat", "prevSize": 32, "code": 59783, - "tempChar": "" + "tempChar": "" }, { "order": 689, @@ -579,7 +603,7 @@ "name": "video", "prevSize": 32, "code": 59784, - "tempChar": "" + "tempChar": "" }, { "order": 686, @@ -587,7 +611,7 @@ "name": "noise-suppression", "prevSize": 32, "code": 59785, - "tempChar": "" + "tempChar": "" }, { "order": 703, @@ -595,7 +619,7 @@ "name": "phone-discard", "prevSize": 32, "code": 59786, - "tempChar": "" + "tempChar": "" }, { "order": 667, @@ -603,7 +627,7 @@ "name": "bot-commands-filled", "prevSize": 32, "code": 59775, - "tempChar": "" + "tempChar": "" }, { "order": 664, @@ -611,7 +635,7 @@ "name": "reply-filled", "prevSize": 32, "code": 59776, - "tempChar": "" + "tempChar": "" }, { "order": 656, @@ -619,7 +643,7 @@ "name": "bug", "prevSize": 32, "code": 59774, - "tempChar": "" + "tempChar": "" }, { "order": 619, @@ -627,7 +651,7 @@ "name": "data", "prevSize": 32, "code": 59773, - "tempChar": "" + "tempChar": "" }, { "order": 622, @@ -635,7 +659,7 @@ "name": "darkmode", "prevSize": 32, "code": 59769, - "tempChar": "" + "tempChar": "" }, { "order": 711, @@ -643,7 +667,7 @@ "name": "animations", "prevSize": 32, "code": 59804, - "tempChar": "" + "tempChar": "" }, { "order": 626, @@ -651,7 +675,7 @@ "name": "enter", "prevSize": 32, "code": 59771, - "tempChar": "" + "tempChar": "" }, { "order": 627, @@ -659,7 +683,7 @@ "name": "fontsize", "prevSize": 32, "code": 59772, - "tempChar": "" + "tempChar": "" }, { "order": 630, @@ -667,7 +691,7 @@ "name": "permissions", "prevSize": 32, "code": 59766, - "tempChar": "" + "tempChar": "" }, { "order": 631, @@ -675,7 +699,7 @@ "name": "card", "prevSize": 32, "code": 59767, - "tempChar": "" + "tempChar": "" }, { "order": 634, @@ -683,7 +707,7 @@ "name": "truck", "prevSize": 32, "code": 59768, - "tempChar": "" + "tempChar": "" }, { "order": 663, @@ -691,7 +715,7 @@ "name": "share-filled", "prevSize": 32, "code": 59738, - "tempChar": "" + "tempChar": "" }, { "order": 638, @@ -699,7 +723,7 @@ "name": "bold", "prevSize": 32, "code": 59745, - "tempChar": "" + "tempChar": "" }, { "order": 639, @@ -707,7 +731,7 @@ "name": "bot-command", "prevSize": 32, "code": 59746, - "tempChar": "" + "tempChar": "" }, { "order": 642, @@ -715,7 +739,7 @@ "name": "calendar-filter", "prevSize": 32, "code": 59747, - "tempChar": "" + "tempChar": "" }, { "order": 643, @@ -723,7 +747,7 @@ "name": "comments", "prevSize": 32, "code": 59748, - "tempChar": "" + "tempChar": "" }, { "order": 645, @@ -731,7 +755,7 @@ "name": "comments-sticker", "prevSize": 32, "code": 59749, - "tempChar": "" + "tempChar": "" }, { "order": 646, @@ -739,7 +763,7 @@ "name": "arrow-down", "prevSize": 32, "code": 59750, - "tempChar": "" + "tempChar": "" }, { "order": 668, @@ -747,7 +771,7 @@ "name": "email", "prevSize": 32, "code": 59751, - "tempChar": "" + "tempChar": "" }, { "order": 648, @@ -755,7 +779,7 @@ "name": "italic", "prevSize": 32, "code": 59752, - "tempChar": "" + "tempChar": "" }, { "order": 620, @@ -763,7 +787,7 @@ "name": "link", "prevSize": 32, "code": 59753, - "tempChar": "" + "tempChar": "" }, { "order": 742, @@ -771,7 +795,7 @@ "name": "link-broken", "prevSize": 32, "code": 59824, - "tempChar": "" + "tempChar": "" }, { "order": 621, @@ -779,7 +803,7 @@ "name": "mention", "prevSize": 32, "code": 59754, - "tempChar": "" + "tempChar": "" }, { "order": 624, @@ -787,7 +811,7 @@ "name": "monospace", "prevSize": 32, "code": 59755, - "tempChar": "" + "tempChar": "" }, { "order": 625, @@ -795,7 +819,7 @@ "name": "next", "prevSize": 32, "code": 59756, - "tempChar": "" + "tempChar": "" }, { "order": 628, @@ -803,7 +827,7 @@ "name": "password-off", "prevSize": 32, "code": 59757, - "tempChar": "" + "tempChar": "" }, { "order": 629, @@ -811,7 +835,7 @@ "name": "pin-list", "prevSize": 32, "code": 59758, - "tempChar": "" + "tempChar": "" }, { "order": 632, @@ -819,7 +843,7 @@ "name": "previous", "prevSize": 32, "code": 59759, - "tempChar": "" + "tempChar": "" }, { "order": 633, @@ -827,7 +851,7 @@ "name": "replace", "prevSize": 32, "code": 59760, - "tempChar": "" + "tempChar": "" }, { "order": 636, @@ -835,7 +859,7 @@ "name": "schedule", "prevSize": 32, "code": 59761, - "tempChar": "" + "tempChar": "" }, { "order": 691, @@ -843,7 +867,7 @@ "name": "strikethrough", "prevSize": 32, "code": 59762, - "tempChar": "" + "tempChar": "" }, { "order": 692, @@ -851,7 +875,7 @@ "name": "underlined", "prevSize": 32, "code": 59763, - "tempChar": "" + "tempChar": "" }, { "order": 641, @@ -859,7 +883,7 @@ "name": "zoom-in", "prevSize": 32, "code": 59764, - "tempChar": "" + "tempChar": "" }, { "order": 649, @@ -867,20 +891,73 @@ "name": "zoom-out", "prevSize": 32, "code": 59765, - "tempChar": "" + "tempChar": "" } ], "id": 2, "metadata": { "name": "Untitled Set", "importSize": { - "width": 24, - "height": 24 + "width": 768, + "height": 768 } }, "height": 1024, "prevSize": 32, "icons": [ + { + "id": 97, + "paths": [ + "M568 156c10.8-4.533 22.8-7.067 35.2-7.067 50.133 0 90.667 40.667 90.667 90.667v233.733l11.333-22.8c20.8-41.733 71.6-58.667 113.333-37.733 41.6 20.8 59.467 70.533 40.533 113.067l-100.667 226.4c-46 103.467-148.667 170.267-261.867 170.267h-23.733c-174.133-0.133-315.467-141.467-315.467-315.733v-272.4c0-50.133 40.667-90.667 90.667-90.667 9.6 0 18.933 1.467 27.733 4.267v-8.4c0-50.133 40.667-90.667 90.667-90.667 12.533 0 24.4 2.533 35.2 7.067 14-32 46-54.533 83.2-54.533 37.2 0.133 69.2 22.533 83.2 54.533zM575.6 239.6v248.667c0 17.333-14.133 31.467-31.467 31.467s-31.467-14.133-31.467-31.467v-296c0-15.333-12.4-27.733-27.733-27.733s-27.733 12.4-27.733 27.733v296c0 17.333-14.133 31.467-31.467 31.467s-31.467-14.133-31.467-31.467v-248.667c0-15.333-12.4-27.733-27.733-27.733s-27.733 12.4-27.733 27.733v272.4c0 17.333-14.133 31.467-31.467 31.467s-31.467-14.133-31.467-31.467v-177.6c0-15.333-12.4-27.733-27.733-27.733s-27.733 12.4-27.733 27.733v272.4c0 139.6 113.2 252.667 252.667 252.667h23.733c88.4 0 168.4-52 204.4-132.8l100.667-226.4c5.2-11.733 0.267-25.333-11.2-31.067-10.667-5.333-23.467-1.067-28.8 9.6l-71.2 142c-14.8 29.733-59.733 19.2-59.733-14.133v-367.067c0-15.333-12.4-27.733-27.733-27.733s-27.6 12.4-27.6 27.733z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "hand-stop" + ] + }, + { + "id": 96, + "paths": [ + "M512 128c212.133 0 384 171.867 384 384s-171.867 384-384 384c-212.133 0-384-171.867-384-384s171.867-384 384-384zM981.333 512c0-259.2-210.133-469.333-469.333-469.333s-469.333 210.133-469.333 469.333c0 259.2 210.133 469.333 469.333 469.333s469.333-210.133 469.333-469.333z", + "M576 512c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", + "M768 512c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z", + "M384 512c0 35.346-28.654 64-64 64s-64-28.654-64-64c0-35.346 28.654-64 64-64s64 28.654 64 64z" + ], + "attrs": [ + {}, + {}, + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "more-circle" + ] + }, + { + "id": 95, + "paths": [ + "M512 42.667c-259.2 0-469.333 210.133-469.333 469.333s210.133 469.333 469.333 469.333 469.333-210.133 469.333-469.333-210.133-469.333-469.333-469.333zM512 896c-212.133 0-384-171.867-384-384s171.867-384 384-384 384 171.867 384 384-171.867 384-384 384z", + "M691.467 631.2c16.667 16.667 16.667 43.733 0 60.4-8.267 8.267-19.2 12.533-30.133 12.533s-21.867-4.133-30.133-12.533l-119.2-119.2-119.2 119.2c-8.267 8.267-19.2 12.533-30.133 12.533s-21.867-4.133-30.133-12.533c-16.667-16.667-16.667-43.733 0-60.4l119.2-119.2-119.2-119.2c-16.667-16.667-16.667-43.733 0-60.4s43.733-16.667 60.4 0l119.2 119.2 119.2-119.2c16.667-16.667 43.733-16.667 60.4 0s16.667 43.733 0 60.4l-119.333 119.2 119.067 119.2z" + ], + "attrs": [ + {}, + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "close-circle" + ] + }, { "id": 94, "paths": [ @@ -3952,7 +4029,7 @@ "name": "spoiler-disable", "prevSize": 32, "code": 59829, - "tempChar": "" + "tempChar": "" }, { "order": 752, @@ -3960,7 +4037,7 @@ "name": "grouped", "prevSize": 32, "code": 59830, - "tempChar": "" + "tempChar": "" }, { "order": 751, @@ -3968,7 +4045,7 @@ "name": "grouped-disable", "prevSize": 32, "code": 59831, - "tempChar": "" + "tempChar": "" }, { "order": 749, @@ -3976,7 +4053,7 @@ "name": "spoiler", "prevSize": 32, "code": 59832, - "tempChar": "" + "tempChar": "" }, { "order": 576, @@ -3984,7 +4061,7 @@ "name": "select", "prevSize": 32, "code": 59744, - "tempChar": "" + "tempChar": "" }, { "order": 480, @@ -3992,7 +4069,7 @@ "name": "folder", "prevSize": 32, "code": 59667, - "tempChar": "" + "tempChar": "" }, { "order": 481, @@ -4000,7 +4077,7 @@ "name": "bots", "prevSize": 32, "code": 59669, - "tempChar": "" + "tempChar": "" }, { "order": 482, @@ -4008,7 +4085,7 @@ "name": "calendar", "prevSize": 32, "code": 59670, - "tempChar": "" + "tempChar": "" }, { "order": 483, @@ -4016,7 +4093,7 @@ "name": "cloud-download", "prevSize": 32, "code": 59671, - "tempChar": "" + "tempChar": "" }, { "order": 484, @@ -4024,7 +4101,7 @@ "name": "colorize", "prevSize": 32, "code": 59672, - "tempChar": "" + "tempChar": "" }, { "order": 651, @@ -4032,7 +4109,7 @@ "name": "forward", "prevSize": 32, "code": 59687, - "tempChar": "" + "tempChar": "" }, { "order": 650, @@ -4040,7 +4117,7 @@ "name": "reply", "prevSize": 32, "code": 59719, - "tempChar": "" + "tempChar": "" }, { "order": 487, @@ -4048,7 +4125,7 @@ "name": "help", "prevSize": 32, "code": 59690, - "tempChar": "" + "tempChar": "" }, { "order": 488, @@ -4056,7 +4133,7 @@ "name": "info", "prevSize": 32, "code": 59691, - "tempChar": "" + "tempChar": "" }, { "order": 489, @@ -4064,7 +4141,7 @@ "name": "info-filled", "prevSize": 32, "code": 59675, - "tempChar": "" + "tempChar": "" }, { "order": 490, @@ -4072,7 +4149,7 @@ "name": "delete-filled", "prevSize": 32, "code": 59676, - "tempChar": "" + "tempChar": "" }, { "order": 491, @@ -4080,7 +4157,7 @@ "name": "delete", "prevSize": 32, "code": 59677, - "tempChar": "" + "tempChar": "" }, { "order": 492, @@ -4088,7 +4165,7 @@ "name": "edit", "prevSize": 32, "code": 59683, - "tempChar": "" + "tempChar": "" }, { "order": 493, @@ -4096,7 +4173,7 @@ "name": "new-chat-filled", "prevSize": 32, "code": 59705, - "tempChar": "" + "tempChar": "" }, { "order": 494, @@ -4104,7 +4181,7 @@ "name": "send", "prevSize": 32, "code": 59722, - "tempChar": "" + "tempChar": "" }, { "order": 495, @@ -4112,7 +4189,7 @@ "name": "send-outline", "prevSize": 32, "code": 59723, - "tempChar": "" + "tempChar": "" }, { "order": 496, @@ -4120,7 +4197,7 @@ "name": "add-user-filled", "prevSize": 32, "code": 59652, - "tempChar": "" + "tempChar": "" }, { "order": 497, @@ -4128,7 +4205,7 @@ "name": "add-user", "prevSize": 32, "code": 59653, - "tempChar": "" + "tempChar": "" }, { "order": 498, @@ -4136,7 +4213,7 @@ "name": "delete-user", "prevSize": 32, "code": 59678, - "tempChar": "" + "tempChar": "" }, { "order": 499, @@ -4144,7 +4221,7 @@ "name": "microphone", "prevSize": 32, "code": 59701, - "tempChar": "" + "tempChar": "" }, { "order": 500, @@ -4152,7 +4229,7 @@ "name": "microphone-alt", "prevSize": 32, "code": 59707, - "tempChar": "" + "tempChar": "" }, { "order": 501, @@ -4160,7 +4237,7 @@ "name": "poll", "prevSize": 32, "code": 59704, - "tempChar": "" + "tempChar": "" }, { "order": 502, @@ -4168,7 +4245,7 @@ "name": "revote", "prevSize": 32, "code": 59706, - "tempChar": "" + "tempChar": "" }, { "order": 503, @@ -4176,7 +4253,7 @@ "name": "photo", "prevSize": 32, "code": 59712, - "tempChar": "" + "tempChar": "" }, { "order": 748, @@ -4184,7 +4261,7 @@ "name": "document", "prevSize": 32, "code": 59679, - "tempChar": "" + "tempChar": "" }, { "order": 505, @@ -4192,7 +4269,7 @@ "name": "camera", "prevSize": 32, "code": 59662, - "tempChar": "" + "tempChar": "" }, { "order": 506, @@ -4200,7 +4277,7 @@ "name": "camera-add", "prevSize": 32, "code": 59663, - "tempChar": "" + "tempChar": "" }, { "order": 507, @@ -4208,7 +4285,7 @@ "name": "logout", "prevSize": 32, "code": 59698, - "tempChar": "" + "tempChar": "" }, { "order": 508, @@ -4216,7 +4293,7 @@ "name": "saved-messages", "prevSize": 32, "code": 59720, - "tempChar": "" + "tempChar": "" }, { "order": 509, @@ -4224,7 +4301,7 @@ "name": "settings", "prevSize": 32, "code": 59726, - "tempChar": "" + "tempChar": "" }, { "order": 652, @@ -4232,7 +4309,7 @@ "name": "phone", "prevSize": 32, "code": 59711, - "tempChar": "" + "tempChar": "" }, { "order": 653, @@ -4240,7 +4317,7 @@ "name": "attach", "prevSize": 32, "code": 59657, - "tempChar": "" + "tempChar": "" }, { "order": 512, @@ -4248,7 +4325,7 @@ "name": "copy", "prevSize": 32, "code": 59674, - "tempChar": "" + "tempChar": "" }, { "order": 513, @@ -4256,7 +4333,7 @@ "name": "channel", "prevSize": 32, "code": 59665, - "tempChar": "" + "tempChar": "" }, { "order": 514, @@ -4264,7 +4341,7 @@ "name": "group", "prevSize": 32, "code": 59689, - "tempChar": "" + "tempChar": "" }, { "order": 515, @@ -4272,7 +4349,7 @@ "name": "user", "prevSize": 32, "code": 59737, - "tempChar": "" + "tempChar": "" }, { "order": 516, @@ -4280,7 +4357,7 @@ "name": "non-contacts", "prevSize": 32, "code": 59688, - "tempChar": "" + "tempChar": "" }, { "order": 517, @@ -4288,7 +4365,7 @@ "name": "active-sessions", "prevSize": 32, "code": 59650, - "tempChar": "" + "tempChar": "" }, { "order": 518, @@ -4296,7 +4373,7 @@ "name": "admin", "prevSize": 32, "code": 59654, - "tempChar": "" + "tempChar": "" }, { "order": 519, @@ -4304,7 +4381,7 @@ "name": "download", "prevSize": 32, "code": 59681, - "tempChar": "" + "tempChar": "" }, { "order": 520, @@ -4312,7 +4389,7 @@ "name": "location", "prevSize": 32, "code": 59696, - "tempChar": "" + "tempChar": "" }, { "order": 521, @@ -4320,7 +4397,7 @@ "name": "stop", "prevSize": 32, "code": 59730, - "tempChar": "" + "tempChar": "" }, { "order": 523, @@ -4328,7 +4405,7 @@ "name": "archive", "prevSize": 32, "code": 59656, - "tempChar": "" + "tempChar": "" }, { "order": 524, @@ -4336,7 +4413,7 @@ "name": "unarchive", "prevSize": 32, "code": 59731, - "tempChar": "" + "tempChar": "" }, { "order": 525, @@ -4344,7 +4421,7 @@ "name": "readchats", "prevSize": 32, "code": 59699, - "tempChar": "" + "tempChar": "" }, { "order": 526, @@ -4352,7 +4429,7 @@ "name": "unread", "prevSize": 32, "code": 59735, - "tempChar": "" + "tempChar": "" }, { "order": 654, @@ -4360,7 +4437,7 @@ "name": "message", "prevSize": 32, "code": 59700, - "tempChar": "" + "tempChar": "" }, { "order": 659, @@ -4368,7 +4445,7 @@ "name": "lock", "prevSize": 32, "code": 59697, - "tempChar": "" + "tempChar": "" }, { "order": 529, @@ -4376,7 +4453,7 @@ "name": "unlock", "prevSize": 32, "code": 59732, - "tempChar": "" + "tempChar": "" }, { "order": 530, @@ -4384,7 +4461,7 @@ "name": "mute", "prevSize": 32, "code": 59703, - "tempChar": "" + "tempChar": "" }, { "order": 531, @@ -4392,7 +4469,7 @@ "name": "unmute", "prevSize": 32, "code": 59733, - "tempChar": "" + "tempChar": "" }, { "order": 532, @@ -4400,7 +4477,7 @@ "name": "pin", "prevSize": 32, "code": 59713, - "tempChar": "" + "tempChar": "" }, { "order": 533, @@ -4408,7 +4485,7 @@ "name": "unpin", "prevSize": 32, "code": 59734, - "tempChar": "" + "tempChar": "" }, { "order": 534, @@ -4416,7 +4493,7 @@ "name": "smallscreen", "prevSize": 32, "code": 59742, - "tempChar": "" + "tempChar": "" }, { "order": 535, @@ -4424,7 +4501,7 @@ "name": "fullscreen", "prevSize": 32, "code": 59743, - "tempChar": "" + "tempChar": "" }, { "order": 536, @@ -4432,7 +4509,7 @@ "name": "large-pause", "prevSize": 32, "code": 59694, - "tempChar": "" + "tempChar": "" }, { "order": 537, @@ -4440,7 +4517,7 @@ "name": "large-play", "prevSize": 32, "code": 59695, - "tempChar": "" + "tempChar": "" }, { "order": 538, @@ -4448,7 +4525,7 @@ "name": "pause", "prevSize": 32, "code": 59709, - "tempChar": "" + "tempChar": "" }, { "order": 539, @@ -4456,7 +4533,7 @@ "name": "play", "prevSize": 32, "code": 59715, - "tempChar": "" + "tempChar": "" }, { "order": 540, @@ -4464,7 +4541,7 @@ "name": "channelviews", "prevSize": 32, "code": 59666, - "tempChar": "" + "tempChar": "" }, { "order": 541, @@ -4472,7 +4549,7 @@ "name": "message-succeeded", "prevSize": 32, "code": 59648, - "tempChar": "" + "tempChar": "" }, { "order": 657, @@ -4480,7 +4557,7 @@ "name": "message-read", "prevSize": 32, "code": 59649, - "tempChar": "" + "tempChar": "" }, { "order": 543, @@ -4488,7 +4565,7 @@ "name": "message-pending", "prevSize": 32, "code": 59724, - "tempChar": "" + "tempChar": "" }, { "order": 544, @@ -4496,7 +4573,7 @@ "name": "message-failed", "prevSize": 32, "code": 59725, - "tempChar": "" + "tempChar": "" }, { "order": 545, @@ -4504,7 +4581,7 @@ "name": "favorite", "prevSize": 32, "code": 59710, - "tempChar": "" + "tempChar": "" }, { "order": 546, @@ -4512,7 +4589,7 @@ "name": "keyboard", "prevSize": 32, "code": 59716, - "tempChar": "" + "tempChar": "" }, { "order": 547, @@ -4520,7 +4597,7 @@ "name": "delete-left", "prevSize": 32, "code": 59717, - "tempChar": "" + "tempChar": "" }, { "order": 548, @@ -4528,7 +4605,7 @@ "name": "recent", "prevSize": 32, "code": 59718, - "tempChar": "" + "tempChar": "" }, { "order": 549, @@ -4536,7 +4613,7 @@ "name": "gifs", "prevSize": 32, "code": 59727, - "tempChar": "" + "tempChar": "" }, { "order": 550, @@ -4544,7 +4621,7 @@ "name": "stickers", "prevSize": 32, "code": 59739, - "tempChar": "" + "tempChar": "" }, { "order": 551, @@ -4552,7 +4629,7 @@ "name": "smile", "prevSize": 32, "code": 59728, - "tempChar": "" + "tempChar": "" }, { "order": 552, @@ -4560,7 +4637,7 @@ "name": "animals", "prevSize": 32, "code": 59655, - "tempChar": "" + "tempChar": "" }, { "order": 553, @@ -4568,7 +4645,7 @@ "name": "eats", "prevSize": 32, "code": 59682, - "tempChar": "" + "tempChar": "" }, { "order": 554, @@ -4576,7 +4653,7 @@ "name": "sport", "prevSize": 32, "code": 59729, - "tempChar": "" + "tempChar": "" }, { "order": 555, @@ -4584,7 +4661,7 @@ "name": "car", "prevSize": 32, "code": 59664, - "tempChar": "" + "tempChar": "" }, { "order": 556, @@ -4592,7 +4669,7 @@ "name": "lamp", "prevSize": 32, "code": 59692, - "tempChar": "" + "tempChar": "" }, { "order": 557, @@ -4600,7 +4677,7 @@ "name": "language", "prevSize": 32, "code": 59693, - "tempChar": "" + "tempChar": "" }, { "order": 558, @@ -4608,7 +4685,7 @@ "name": "flag", "prevSize": 32, "code": 59686, - "tempChar": "" + "tempChar": "" }, { "order": 559, @@ -4616,7 +4693,7 @@ "name": "more", "prevSize": 32, "code": 59702, - "tempChar": "" + "tempChar": "" }, { "order": 560, @@ -4624,7 +4701,7 @@ "name": "search", "prevSize": 32, "code": 59721, - "tempChar": "" + "tempChar": "" }, { "order": 561, @@ -4632,7 +4709,7 @@ "name": "remove", "prevSize": 32, "code": 59740, - "tempChar": "" + "tempChar": "" }, { "order": 562, @@ -4640,7 +4717,7 @@ "name": "add", "prevSize": 32, "code": 59651, - "tempChar": "" + "tempChar": "" }, { "order": 563, @@ -4648,7 +4725,7 @@ "name": "check", "prevSize": 32, "code": 59668, - "tempChar": "" + "tempChar": "" }, { "order": 564, @@ -4656,7 +4733,7 @@ "name": "close", "prevSize": 32, "code": 59673, - "tempChar": "" + "tempChar": "" }, { "order": 610, @@ -4664,7 +4741,7 @@ "name": "arrow-left", "prevSize": 32, "code": 59661, - "tempChar": "" + "tempChar": "" }, { "order": 566, @@ -4672,7 +4749,7 @@ "name": "arrow-right", "prevSize": 32, "code": 59708, - "tempChar": "" + "tempChar": "" }, { "order": 730, @@ -4680,7 +4757,7 @@ "name": "down", "prevSize": 32, "code": 59680, - "tempChar": "" + "tempChar": "" }, { "order": 568, @@ -4688,7 +4765,7 @@ "name": "up", "prevSize": 32, "code": 59736, - "tempChar": "" + "tempChar": "" }, { "order": 569, @@ -4696,7 +4773,7 @@ "name": "eye-closed", "prevSize": 32, "code": 59685, - "tempChar": "" + "tempChar": "" }, { "order": 570, @@ -4704,7 +4781,7 @@ "name": "eye", "prevSize": 32, "code": 59684, - "tempChar": "" + "tempChar": "" }, { "order": 571, @@ -4712,7 +4789,7 @@ "name": "muted", "prevSize": 32, "code": 59741, - "tempChar": "" + "tempChar": "" }, { "order": 572, @@ -4720,7 +4797,7 @@ "name": "avatar-archived-chats", "prevSize": 32, "code": 59658, - "tempChar": "" + "tempChar": "" }, { "order": 573, @@ -4728,7 +4805,7 @@ "name": "avatar-deleted-account", "prevSize": 32, "code": 59659, - "tempChar": "" + "tempChar": "" }, { "order": 747, @@ -4736,7 +4813,7 @@ "name": "avatar-saved-messages", "prevSize": 32, "code": 59660, - "tempChar": "" + "tempChar": "" }, { "order": 575, @@ -4744,7 +4821,7 @@ "name": "pinned-chat", "prevSize": 32, "code": 59714, - "tempChar": "" + "tempChar": "" } ], "prevSize": 32, diff --git a/src/styles/icons.scss b/src/styles/icons.scss index aeb9e2b9b..cb3ef2ab7 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -49,6 +49,15 @@ .icon-volume-3:before { content: "\e991"; } +.icon-hand-stop:before { + content: "\e9c3"; +} +.icon-more-circle:before { + content: "\e9c4"; +} +.icon-close-circle:before { + content: "\e9c5"; +} .icon-settings-filled:before { content: "\e9c1"; } diff --git a/src/types/index.ts b/src/types/index.ts index 34ca05a4d..8a51233d5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,8 @@ export interface ISettings extends NotifySettings, Record { isConnectionStatusMinimized: boolean; shouldArchiveAndMuteNewNonContact?: boolean; canTranslate: boolean; + canTranslateChats: boolean; + translationLanguage?: string; doNotTranslate: string[]; canDisplayChatInTitle: boolean; shouldShowLoginCodeInChatList?: boolean; diff --git a/src/util/moduleLoader.ts b/src/util/moduleLoader.ts index a70462028..bb4f9dae9 100644 --- a/src/util/moduleLoader.ts +++ b/src/util/moduleLoader.ts @@ -64,7 +64,9 @@ export async function loadModule(bundleName: B) { await loadBundle(bundleName); } -export function getModuleFromMemory>(bundleName: B, moduleName: M) { +export function getModuleFromMemory>( + bundleName: B, moduleName: M, +): ImportedBundles[B][M] | undefined { const bundle = MEMORY_CACHE[bundleName] as ImportedBundles[B]; if (!bundle) { diff --git a/src/util/primitives/LimitedMap.ts b/src/util/primitives/LimitedMap.ts new file mode 100644 index 000000000..9e8ec0768 --- /dev/null +++ b/src/util/primitives/LimitedMap.ts @@ -0,0 +1,74 @@ +/** + * A Map that has a limited size. When the limit is reached, the oldest entry is removed. + * Ignores last access time, only cares about insertion order. + */ +export default class LimitedMap { + private map: Map; + + private insertionQueue: Set; + + constructor(private limit: number) { + this.map = new Map(); + this.insertionQueue = new Set(); + } + + public get(key: K): V | undefined { + return this.map.get(key); + } + + public set(key: K, value: V): this { + if (this.map.size === this.limit) { + const keyToRemove = Array.from(this.insertionQueue).shift(); + if (keyToRemove) { + this.map.delete(keyToRemove); + this.insertionQueue.delete(keyToRemove); + } + } + + this.map.set(key, value); + this.insertionQueue.add(key); + + return this; + } + + public delete(key: K): boolean { + const result = this.map.delete(key); + if (result) { + this.insertionQueue.delete(key); + } + return result; + } + + public clear(): void { + this.map.clear(); + this.insertionQueue.clear(); + } + + public forEach(callbackfn: (value: V, key: K, map: Map) => void, thisArg?: any): void { + this.map.forEach(callbackfn, thisArg); + } + + public get size(): number { + return this.map.size; + } + + public get [Symbol.toStringTag](): string { + return this.map[Symbol.toStringTag]; + } + + public [Symbol.iterator](): IterableIterator<[K, V]> { + return this.map[Symbol.iterator](); + } + + public entries(): IterableIterator<[K, V]> { + return this.map.entries(); + } + + public keys(): IterableIterator { + return this.map.keys(); + } + + public values(): IterableIterator { + return this.map.values(); + } +} diff --git a/src/util/switchTheme.ts b/src/util/switchTheme.ts index b59640330..a307eeb65 100644 --- a/src/util/switchTheme.ts +++ b/src/util/switchTheme.ts @@ -108,14 +108,24 @@ export function hexToRgb(hex: string): RGBAColor { }; } +export function lerpRgb(start: RGBAColor, end: RGBAColor, interpolationRatio: number): RGBAColor { + const r = Math.round(lerp(start.r, end.r, interpolationRatio)); + const g = Math.round(lerp(start.g, end.g, interpolationRatio)); + const b = Math.round(lerp(start.b, end.b, interpolationRatio)); + const a = start.a !== undefined + ? Math.round(lerp(start.a!, end.a!, interpolationRatio)) + : undefined; + + return { + r, g, b, a, + }; +} + function applyColorAnimationStep(startIndex: number, endIndex: number, interpolationRatio: number = 1) { colors.forEach(({ property, colors: propertyColors }) => { - const r = Math.round(lerp(propertyColors[startIndex].r, propertyColors[endIndex].r, interpolationRatio)); - const g = Math.round(lerp(propertyColors[startIndex].g, propertyColors[endIndex].g, interpolationRatio)); - const b = Math.round(lerp(propertyColors[startIndex].b, propertyColors[endIndex].b, interpolationRatio)); - const a = propertyColors[startIndex].a !== undefined - ? Math.round(lerp(propertyColors[startIndex].a!, propertyColors[endIndex].a!, interpolationRatio)) - : undefined; + const { + r, g, b, a, + } = lerpRgb(propertyColors[startIndex], propertyColors[endIndex], interpolationRatio); const roundedA = a !== undefined ? Math.round((a / 255) * 10 ** DECIMAL_PLACES) / 10 ** DECIMAL_PLACES : undefined;