Message List: Add ability to translate entire chats (#3464)

This commit is contained in:
Alexander Zinchuk 2023-07-20 15:58:39 +02:00
parent ef02a4a11d
commit cdedb486f4
52 changed files with 1191 additions and 332 deletions

View File

@ -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) }),

View File

@ -412,6 +412,7 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
recentRequesters,
requestsPending,
chatPhoto,
translationsDisabled,
} = result.fullChat;
if (chatPhoto instanceof GramJs.Photo) {
@ -437,6 +438,7 @@ async function getFullChatInfo(chatId: string): Promise<FullChatData | undefined
enabledReactions: buildApiChatReactions(availableReactions),
requestsPending,
recentRequesterIds: recentRequesters?.map((userId) => 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),
}));
}

View File

@ -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 {

View File

@ -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 {

View File

@ -42,6 +42,7 @@ export interface ApiUserFullInfo {
personalPhoto?: ApiPhoto;
noVoiceMessages?: boolean;
premiumGifts?: ApiPremiumGiftOption[];
isTranslationDisabled?: true;
}
export type ApiFakeType = 'fake' | 'scam';

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,3 @@
<svg width="32" height="33" viewBox="0 0 32 33" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16.0988 19.1657L14.2578 17.3464L14.2843 17.3199C15.8244 15.6028 16.9219 13.6291 17.568 11.5403H19.2762C19.765 11.5403 20.1613 11.1441 20.1613 10.6552C20.1613 10.1664 19.765 9.77016 19.2762 9.77016H13.9657V8.88508C13.9657 8.39627 13.5694 8 13.0806 8C12.5918 8 12.1956 8.39627 12.1956 8.88508V9.77016H6.88065C6.3943 9.77016 6 10.1644 6 10.6508C6 11.1372 6.39428 11.5315 6.88065 11.5315H15.8863C15.2933 13.2397 14.3551 14.8594 13.0806 16.2755C12.4525 15.5798 11.9067 14.8325 11.4435 14.0494L11.283 13.7671C11.1272 13.4854 10.8306 13.3104 10.5086 13.3104H10.1511C9.8449 13.3104 9.59671 13.5586 9.59671 13.8648C9.59671 13.9526 9.6176 14.0392 9.65759 14.1173L9.73838 14.2719C10.3217 15.36 11.0462 16.393 11.9035 17.3464L8.0312 21.1654C7.71086 21.4814 7.6829 21.9818 7.94972 22.3299L8.02943 22.4205C8.37506 22.7661 8.93548 22.7661 9.28113 22.4205L13.0807 18.6209L15.1521 20.6923C15.3937 20.934 15.7856 20.934 16.0273 20.6923C16.0901 20.6295 16.1386 20.554 16.1696 20.4707L16.3061 20.1042C16.4278 19.7778 16.3466 19.4105 16.0989 19.1657L16.0988 19.1657ZM20.8755 15.0806H20.3321C19.9632 15.0806 19.6329 15.3095 19.5034 15.6549L16.0466 24.8729C15.9277 25.19 16.0884 25.5436 16.4056 25.6625C16.4744 25.6884 16.5474 25.7016 16.6209 25.7016H16.8917C17.2611 25.7016 17.5916 25.4722 17.7209 25.1261L18.4973 23.0463H22.7014L23.4857 25.1285C23.6156 25.4733 23.9455 25.7016 24.314 25.7016H24.5866C24.9254 25.7016 25.2 25.427 25.2 25.0882C25.2 25.0147 25.1868 24.9417 25.1609 24.8729L21.7042 15.6549C21.5747 15.3095 21.2444 15.0806 20.8755 15.0806H20.8755ZM20.6038 17.4438L22.0376 21.2762H19.17L20.6038 17.4438Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -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';

View File

@ -172,4 +172,12 @@
color: var(--color-text-secondary);
}
}
.embed-loading {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}

View File

@ -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<OwnProps> = ({
isProtected,
noUserColors,
hasContextMenu,
chatTranslations,
requestedChatTranslationLanguage,
observeIntersectionForLoading,
observeIntersectionForPlaying,
onClick,
@ -68,6 +77,16 @@ const EmbeddedMessage: FC<OwnProps> = ({
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<OwnProps> = ({
onClick={message && handleClick}
onMouseDown={message && handleMouseDown}
>
{shouldRenderLoader && <Skeleton className={buildClassName('embed-loading', transitionClassNames)} />}
{mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isRoundVideo, isProtected, isSpoiler)}
<div className="message-text">
<p dir="auto">
@ -102,6 +122,7 @@ const EmbeddedMessage: FC<OwnProps> = ({
lang={lang}
message={message}
noEmoji={Boolean(mediaThumbnail)}
translatedText={translatedText}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
/>

View File

@ -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 (
<span>
@ -64,6 +67,7 @@ function MessageSummary({
return (
<MessageText
message={message}
translatedText={translatedText}
highlight={highlight}
isSimple
observeIntersectionForLoading={observeIntersectionForLoading}

View File

@ -6,6 +6,7 @@
.item {
overflow: hidden;
min-height: 25rem;
padding-bottom: 0 !important;
}
.checkbox {
@ -14,4 +15,5 @@
.languages {
overflow-y: auto;
margin-bottom: 0 !important;
}

View File

@ -1,5 +1,5 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
memo, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
@ -10,10 +10,12 @@ import type { ApiLanguage } from '../../../api/types';
import { setLanguage } from '../../../util/langProvider';
import { IS_TRANSLATION_SUPPORTED } from '../../../util/windowEnvironment';
import { selectIsCurrentUserPremium } from '../../../global/selectors';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import useLastCallback from '../../../hooks/useLastCallback';
import RadioGroup from '../../ui/RadioGroup';
import Loading from '../../ui/Loading';
@ -26,13 +28,17 @@ type OwnProps = {
onScreenSelect: (screen: SettingsScreens) => void;
};
type StateProps = Pick<ISettings, 'languages' | 'language' | 'canTranslate' | 'doNotTranslate'>;
type StateProps = {
isCurrentUserPremium: boolean;
} & Pick<ISettings, 'languages' | 'language' | 'canTranslate' | 'canTranslateChats' | 'doNotTranslate'>;
const SettingsLanguage: FC<OwnProps & StateProps> = ({
isActive,
isCurrentUserPremium,
languages,
language,
canTranslate,
canTranslateChats,
doNotTranslate,
onScreenSelect,
onReset,
@ -41,11 +47,14 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
loadLanguages,
loadAttachBots,
setSettingOption,
openPremiumModal,
} = getActions();
const [selectedLanguage, setSelectedLanguage] = useState<string>(language);
const [isLoading, markIsLoading, unmarkIsLoading] = useFlag();
const canTranslateChatsEnabled = isCurrentUserPremium && canTranslateChats;
const lang = useLang();
useEffect(() => {
@ -54,7 +63,7 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
}
}, [languages]);
const handleChange = useCallback((langCode: string) => {
const handleChange = useLastCallback((langCode: string) => {
setSelectedLanguage(langCode);
markIsLoading();
@ -65,15 +74,27 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
{IS_TRANSLATION_SUPPORTED && (
<div className="settings-item">
<Checkbox
className="pb-2"
label={lang('ShowTranslateButton')}
checked={canTranslate}
onCheck={handleShouldTranslateChange}
/>
{canTranslate && (
<Checkbox
className="pb-2"
label={lang('ShowTranslateChatButton')}
checked={canTranslateChatsEnabled}
disabled={!isCurrentUserPremium}
rightIcon={!isCurrentUserPremium ? 'lock' : undefined}
onClickLabel={handleShouldTranslateChatsClick}
onCheck={handleShouldTranslateChatsChange}
/>
{(canTranslate || canTranslateChatsEnabled) && (
<ListItem
onClick={handleDoNotSelectOpen}
>
@ -154,13 +183,17 @@ function buildOptions(languages: ApiLanguage[]) {
export default memo(withGlobal<OwnProps>(
(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,
};
},

View File

@ -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<OwnProps> = ({
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 (
<ListItem buttonClassName={styles.root} onClick={onClick}>
<img src={icon} className={styles.icon} alt="" style={`--item-color: ${COLORS[index]}`} />
<img src={icon} className={styles.icon} alt="" style={`--item-color: rgb(${r},${g},${b})`} />
<div className={styles.text}>
<div className={styles.title}>{renderText(title, ['br'])}</div>
<div className={styles.description}>{text}</div>

View File

@ -35,6 +35,7 @@ export const PREMIUM_FEATURE_TITLES: Record<string, string> = {
advanced_chat_management: 'PremiumPreviewAdvancedChatManagement',
animated_userpics: 'PremiumPreviewAnimatedProfiles',
emoji_status: 'PremiumPreviewEmojiStatus',
translations: 'PremiumPreviewTranslations',
};
export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
@ -50,6 +51,7 @@ export const PREMIUM_FEATURE_DESCRIPTIONS: Record<string, string> = {
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<ApiLimitType, 'uploadMaxFileparts' | 'chatlistInvites' | 'chatlistJoined'>;

View File

@ -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<string, string> = {
advanced_chat_management: PremiumChats,
animated_userpics: PremiumVideo,
emoji_status: PremiumStatus,
translations: PremiumTranslate,
};
export type OwnProps = {
@ -275,6 +277,7 @@ const PremiumMainModal: FC<OwnProps & StateProps> = ({
: lang(PREMIUM_FEATURE_DESCRIPTIONS[section])}
icon={PREMIUM_FEATURE_COLOR_ICONS[section]}
index={index}
count={filteredSections.length}
onClick={handleOpen(section)}
/>
);

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const ChatLanguageModal = useModuleLoader(Bundles.Extra, 'ChatLanguageModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return ChatLanguageModal ? <ChatLanguageModal {...props} /> : undefined;
};
export default ChatLanguageModalAsync;

View File

@ -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<OwnProps & StateProps> = ({
const ChatLanguageModal: FC<OwnProps & StateProps> = ({
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<HTMLInputElement>) => {
@ -96,7 +112,7 @@ const MessageLanguageModal: FC<OwnProps & StateProps> = ({
isOpen={isOpen}
hasCloseButton
title={lang('Language')}
onClose={closeMessageLanguageModal}
onClose={closeChatLanguageModal}
>
<InputText
key="search"
@ -109,7 +125,7 @@ const MessageLanguageModal: FC<OwnProps & StateProps> = ({
{filteredDisplayedLanguages.map(({ langCode, originalName, translatedName }) => (
<ListItem
key={langCode}
className={styles.listItem}
className={buildClassName(styles.listItem, 'no-icon')}
secondaryIcon={activeTranslationLanguage === langCode ? 'check' : undefined}
disabled={activeTranslationLanguage === langCode}
multiline
@ -132,11 +148,14 @@ const MessageLanguageModal: FC<OwnProps & StateProps> = ({
export default memo(withGlobal<OwnProps>(
(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<OwnProps>(
currentLanguageCode,
};
},
)(MessageLanguageModal));
)(ChatLanguageModal));

View File

@ -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<OwnProps & StateProps> = ({
shouldJoinToSend,
shouldSendJoinRequest,
noAnimation,
canTranslate,
isTranslating,
translationLanguage,
language,
detectedChatLanguage,
doNotTranslate,
onTopicSearch,
}) => {
const {
@ -98,6 +120,10 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
requestNextManagementScreen,
showNotification,
openChat,
requestChatTranslation,
togglePeerTranslations,
openChatLanguageModal,
setSettingOption,
} = getActions();
// eslint-disable-next-line no-null/no-null
const menuButtonRef = useRef<HTMLButtonElement>(null);
@ -136,6 +162,15 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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 }) => (
<Button
round
ripple={isRightColumnShown}
color="translucent"
size="smaller"
className={isOpen ? 'active' : ''}
onClick={onTrigger}
ariaLabel={lang('TranslateMessage')}
>
<i className="icon icon-language" aria-hidden />
</Button>
);
}, [isRightColumnShown, lang]);
return (
<div className="HeaderActions">
{canTranslate && (
<DropdownMenu
className="stickers-more-menu with-menu-transitions"
trigger={MoreMenuButton}
positionX="right"
>
<MenuItem icon="language" onClick={handleTranslateClick}>
{buttonText}
</MenuItem>
<MenuItem icon="replace" onClick={handleChangeLanguage}>
{lang('Chat.Translate.Menu.To')}
</MenuItem>
<MenuSeparator />
{detectedChatLanguage
&& <MenuItem icon="hand-stop" onClick={handleDoNotTranslate}>{doNotTranslateText}</MenuItem>}
<MenuItem icon="close-circle" onClick={handleHide}>{lang('Hide')}</MenuItem>
</DropdownMenu>
)}
{!isMobile && (
<>
{canExpandActions && !shouldSendJoinRequest && (canSubscribe || shouldJoinToSend) && (
@ -234,9 +348,9 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
color="translucent"
size="smaller"
onClick={handleSearchClick}
ariaLabel="Search in this chat"
ariaLabel={lang('Conversation.SearchPlaceholder')}
>
<i className="icon icon-search" />
<i className="icon icon-search" aria-hidden />
</Button>
)}
{canCall && (
@ -248,7 +362,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
onClick={handleRequestCall}
ariaLabel="Call"
>
<i className="icon icon-phone" />
<i className="icon icon-phone" aria-hidden />
</Button>
)}
</>
@ -263,7 +377,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
onClick={handleJoinRequestsClick}
ariaLabel={isChannel ? lang('SubscribeRequests') : lang('MemberRequests')}
>
<i className="icon icon-user" />
<i className="icon icon-user" aria-hidden />
<div className="badge">{pendingJoinRequests}</div>
</Button>
)}
@ -278,7 +392,7 @@ const HeaderActions: FC<OwnProps & StateProps> = ({
ariaLabel="More actions"
onClick={handleHeaderMenuOpen}
>
<i className="icon icon-more" />
<i className="icon icon-more" aria-hidden />
</Button>
{menuPosition && (
<HeaderMenuContainer
@ -318,15 +432,23 @@ export default memo(withGlobal<OwnProps>(
}): 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<OwnProps>(
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<OwnProps>(
shouldJoinToSend,
shouldSendJoinRequest,
noAnimation,
canTranslate,
isTranslating,
translationLanguage,
language,
doNotTranslate,
detectedChatLanguage: chat.detectedLanguage,
};
},
)(HeaderActions));

View File

@ -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<OwnProps & StateProps> = ({
canEditTopic,
canManage,
isRightColumnShown,
canTranslate,
onJoinRequestsClick,
onSubscribeChannel,
onSearchClick,
@ -174,6 +177,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
openEditTopicPanel,
openChat,
toggleManagement,
togglePeerTranslations,
} = getActions();
const { isMobile } = useAppLayout();
@ -323,6 +327,11 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
});
const handleEnableTranslations = useLastCallback(() => {
togglePeerTranslations({ chatId, isEnabled: true });
closeMenu();
});
const handleSelectMessages = useLastCallback(() => {
enterMessageSelectMode();
closeMenu();
@ -538,6 +547,14 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
{lang('Statistics')}
</MenuItem>
)}
{canTranslate && (
<MenuItem
icon="language"
onClick={handleEnableTranslations}
>
{lang('lng_context_translate')}
</MenuItem>
)}
{canReportChat && (
<MenuItem
icon="flag"
@ -614,6 +631,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
);
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<OwnProps>(
canEditTopic,
canManage,
isRightColumnShown: selectIsRightColumnShown(global),
canTranslate,
};
},
)(HeaderMenuContainer));

View File

@ -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<OwnProps> = (props) => {
const { isOpen } = props;
const MessageLanguageModal = useModuleLoader(Bundles.Extra, 'MessageLanguageModal', !isOpen);
// eslint-disable-next-line react/jsx-props-no-spreading
return MessageLanguageModal ? <MessageLanguageModal {...props} /> : undefined;
};
export default MessageLanguageModalAsync;

View File

@ -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({
/>
<SeenByModal isOpen={isSeenByModalOpen} />
<ReactorListModal isOpen={isReactorListModalOpen} />
{IS_TRANSLATION_SUPPORTED && <MessageLanguageModal isOpen={isMessageLanguageModalOpen} />}
{IS_TRANSLATION_SUPPORTED && <ChatLanguageModal isOpen={isChatLanguageModalOpen} />}
</div>
</Transition>
@ -668,7 +668,7 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
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,

View File

@ -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<OwnProps & StateProps> = ({
toggleReaction,
requestMessageTranslation,
showOriginalMessage,
openMessageLanguageModal,
openChatLanguageModal,
openReactionPicker,
} = getActions();
@ -455,9 +455,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
});
const handleSelectLanguage = useLastCallback(() => {
openMessageLanguageModal({
openChatLanguageModal({
chatId: message.chatId,
id: message.id,
messageId: message.id,
});
closeMenu();
});
@ -610,7 +610,6 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
const customEmojiSets = customEmojiSetsNotFiltered?.every<ApiStickerSet>(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<OwnProps>(
canScheduleUntilOnline: selectCanScheduleUntilOnline(global, message.chatId),
threadId,
canTranslate,
canShowOriginal: hasTranslation,
canSelectLanguage: hasTranslation,
canShowOriginal: hasTranslation && !isChatTranslated,
canSelectLanguage: hasTranslation && !isChatTranslated,
canPlayAnimatedEmojis: selectCanPlayAnimatedEmojis(global),
isReactionPickerOpen: selectIsReactionPickerOpen(global),
};

View File

@ -70,7 +70,7 @@ const Location: FC<OwnProps> = ({
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;

View File

@ -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<OwnProps & StateProps> = ({
hasTopicChip,
chatTranslations,
areTranslationsEnabled,
shouldDetectChatLanguage,
requestedTranslationLanguage,
requestedChatTranslationLanguage,
withReactionEffects,
withStickerEffects,
isConnected,
@ -552,6 +559,7 @@ const Message: FC<OwnProps & StateProps> = ({
senderPeer,
botSender,
messageTopic,
Boolean(requestedChatTranslationLanguage),
);
useEffect(() => {
@ -597,13 +605,16 @@ const Message: FC<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
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<OwnProps>(
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<OwnProps>(
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'),

View File

@ -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<string, LimitedMap<number, MessageMetadata>>();
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<number, MessageMetadata>(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<string, number>();
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,
});
}

View File

@ -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<HTMLDivElement>) => {
e.stopPropagation();
openMessageLanguageModal({ chatId, id: messageId });
openChatLanguageModal({ chatId, messageId: !isTranslatingChat ? messageId : undefined });
});
const handleOpenThread = useLastCallback(() => {

View File

@ -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<string, Map<string, number[]>>();
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<string, number[]>();
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);
}

View File

@ -8,7 +8,7 @@
cursor: var(--custom-cursor, pointer);
&.disabled {
pointer-events: none;
cursor: var(--custom-cursor, default);
opacity: 0.5;
}

View File

@ -54,6 +54,10 @@ const Checkbox: FC<OwnProps> = ({
const labelRef = useRef<HTMLLabelElement>(null);
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
if (disabled) {
return;
}
if (onChange) {
onChange(event);
}
@ -61,7 +65,7 @@ const Checkbox: FC<OwnProps> = ({
if (onCheck) {
onCheck(event.currentTarget.checked);
}
}, [onChange, onCheck]);
}, [disabled, onChange, onCheck]);
function handleClick(event: React.MouseEvent) {
if (event.target !== labelRef.current) {

View File

@ -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<void> => {
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,

View File

@ -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,

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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) {

View File

@ -221,6 +221,7 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
isConnectionStatusMinimized: true,
shouldArchiveAndMuteNewNonContact: false,
canTranslate: false,
canTranslateChats: true,
doNotTranslate: [],
canDisplayChatInTitle: true,
shouldAllowHttpTransport: true,

View File

@ -79,6 +79,40 @@ export function updateMessageTranslations<T extends GlobalState>(
return global;
}
export function updateRequestedChatTranslation<T extends GlobalState>(
global: T, chatId: string, toLanguageCode?: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
requestedTranslations: {
...tabState.requestedTranslations,
byChatId: {
...tabState.requestedTranslations.byChatId,
[chatId]: {
toLanguage: toLanguageCode,
},
},
},
}, tabId);
return global;
}
export function removeRequestedChatTranslation<T extends GlobalState>(
global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
requestedTranslations: {
...tabState.requestedTranslations,
byChatId: omit(tabState.requestedTranslations.byChatId, [chatId]),
},
}, tabId);
return global;
}
export function updateRequestedMessageTranslation<T extends GlobalState>(
global: T, chatId: string, messageId: number, toLanguageCode: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {

View File

@ -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<T extends GlobalState>(global: T, chatId: string): ApiChat | undefined {
return global.chats.byId[chatId];
@ -61,7 +64,7 @@ export function selectChatOnlineCount<T extends GlobalState>(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<T extends GlobalState>(global: T, folderId:
return selectCanInviteToChat(global, chatId);
});
}
export function selectShouldDetectChatLanguage<T extends GlobalState>(
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<T extends GlobalState>(
global: T, chatId: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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<T extends GlobalState>(
global: T, chatId: string,
...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const { requestedTranslations } = selectTabState(global, tabId);
return requestedTranslations.byChatId[chatId]?.toLanguage;
}

View File

@ -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<T extends GlobalState>(
return selectChatTranslations(global, chatId)?.byLangCode[toLanguageCode] || {};
}
export function selectRequestedTranslationLanguage<T extends GlobalState>(
global: T, chatId: string, messageId: number, tabId = getCurrentTabId(),
export function selectRequestedMessageTranslationLanguage<T extends GlobalState>(
global: T, chatId: string, messageId: number, ...[tabId = getCurrentTabId()]: TabArgs<T>
): 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<T extends GlobalState>(
@ -1315,3 +1317,19 @@ export function selectForwardsCanBeSentToChat<T extends GlobalState>(
|| (isPlainText && !canSendPlainText);
});
}
export function selectCanTranslateMessage<T extends GlobalState>(
global: T, message: ApiMessage, detectedLanguage?: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
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;
}

View File

@ -15,3 +15,7 @@ export function selectLanguageCode<T extends GlobalState>(global: T) {
export function selectCanSetPasscode<T extends GlobalState>(global: T) {
return global.authRememberMe && global.isCacheApiSupported;
}
export function selectTranslationLanguage<T extends GlobalState>(global: T) {
return global.settings.byKey.translationLanguage || selectLanguageCode(global);
}

View File

@ -564,9 +564,9 @@ export type TabState = {
requestedTranslations: {
byChatId: Record<string, ChatRequestedTranslations>;
};
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[];

View File

@ -4,14 +4,16 @@ import { detectLanguage } from '../util/languageDetection';
import useSyncEffect from './useSyncEffect';
export default function useTextLanguage(text?: string) {
const [language, setLanguage] = useState<string>();
export default function useTextLanguage(text?: string, isDisabled?: boolean) {
const [language, setLanguage] = useState<string | undefined>();
useSyncEffect(() => {
if (text) {
if (text && !isDisabled) {
detectLanguage(text).then(setLanguage);
} else {
setLanguage(undefined);
}
}, [text]);
}, [isDisabled, text]);
return language;
}

View File

@ -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<int> = 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;

View File

@ -166,6 +166,7 @@
"messages.getExtendedMedia",
"messages.getBotApp",
"messages.requestAppWebView",
"messages.togglePeerTranslations",
"updates.getState",
"updates.getDifference",
"updates.getChannelDifference",

File diff suppressed because it is too large Load Diff

View File

@ -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";
}

View File

@ -97,6 +97,8 @@ export interface ISettings extends NotifySettings, Record<string, any> {
isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean;
canTranslate: boolean;
canTranslateChats: boolean;
translationLanguage?: string;
doNotTranslate: string[];
canDisplayChatInTitle: boolean;
shouldShowLoginCodeInChatList?: boolean;

View File

@ -64,7 +64,9 @@ export async function loadModule<B extends Bundles>(bundleName: B) {
await loadBundle(bundleName);
}
export function getModuleFromMemory<B extends Bundles, M extends BundleModules<B>>(bundleName: B, moduleName: M) {
export function getModuleFromMemory<B extends Bundles, M extends BundleModules<B>>(
bundleName: B, moduleName: M,
): ImportedBundles[B][M] | undefined {
const bundle = MEMORY_CACHE[bundleName] as ImportedBundles[B];
if (!bundle) {

View File

@ -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<K, V> {
private map: Map<K, V>;
private insertionQueue: Set<K>;
constructor(private limit: number) {
this.map = new Map();
this.insertionQueue = new Set<K>();
}
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<K, V>) => 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<K> {
return this.map.keys();
}
public values(): IterableIterator<V> {
return this.map.values();
}
}

View File

@ -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;