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 023beb669..904841087 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index b41c21756..ae34270fb 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/assets/premium/PremiumTranslate.svg b/src/assets/premium/PremiumTranslate.svg new file mode 100644 index 000000000..8f31a89c2 --- /dev/null +++ b/src/assets/premium/PremiumTranslate.svg @@ -0,0 +1,3 @@ + + + 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;