Message Context Menu: Introduce Translate feature

This commit is contained in:
Alexander Zinchuk 2023-02-28 18:43:36 +01:00
parent da70c3893d
commit 515394d35e
63 changed files with 1736 additions and 327 deletions

View File

@ -1,6 +1,7 @@
src/lib/rlottie/rlottie-wasm.js
src/lib/webp/webp_wasm.js
src/lib/fasttextweb/fasttext-wasm.js
src/lib/gramjs/tl/types-generator/template.js
src/lib/gramjs/tl/api.d.ts

View File

@ -61,6 +61,7 @@ type AllEmojis = Record<string, Emoji | EmojiWithSkins>;
declare module '*.png';
declare module '*.svg';
declare module '*.tgs';
declare module '*.wasm';
declare module '*.txt' {
const content: string;

View File

@ -1616,3 +1616,12 @@ function buildThreadInfo(
...(recentRepliers && { recentReplierIds: recentRepliers.map(getApiChatIdFromMtpPeer) }),
};
}
export function buildApiFormattedText(textWithEntities: GramJs.TextWithEntities): ApiFormattedText {
const { text, entities } = textWithEntities;
return {
text,
entities: entities.map(buildApiMessageEntity),
};
}

View File

@ -22,6 +22,7 @@ import type {
ApiRequestInputInvoice,
ApiChatReactions,
ApiReaction,
ApiFormattedText,
} from '../../types';
import {
ApiMessageEntityTypes,
@ -598,3 +599,10 @@ export function buildInputEmojiStatus(emojiStatus: ApiSticker, expires?: number)
documentId: BigInt(emojiStatus.id),
});
}
export function buildInputTextWithEntities(formatted: ApiFormattedText) {
return new GramJs.TextWithEntities({
text: formatted.text,
entities: formatted.entities?.map(buildMtpMessageEntity) || [],
});
}

View File

@ -30,7 +30,7 @@ export {
fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages,
reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs,
saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, transcribeAudio,
closePoll, fetchExtendedMedia,
closePoll, fetchExtendedMedia, translateText,
} from './messages';
export {

View File

@ -16,6 +16,7 @@ import type {
ApiSendMessageAction,
ApiContact,
ApiPoll,
ApiFormattedText,
} from '../../types';
import {
MAIN_THREAD_ID,
@ -36,6 +37,7 @@ import {
buildLocalMessage,
buildWebPage,
buildApiSponsoredMessage,
buildApiFormattedText,
} from '../apiBuilders/messages';
import { buildApiUser } from '../apiBuilders/users';
import {
@ -51,6 +53,7 @@ import {
isServiceMessageWithMedia,
buildSendMessageAction,
buildInputPollFromExisting,
buildInputTextWithEntities,
} from '../gramjsBuilders';
import localDb from '../localDb';
import { buildApiChatFromPreview, buildApiSendAsPeerId } from '../apiBuilders/chats';
@ -69,6 +72,15 @@ import { getServerTimeOffset } from '../../../util/serverTime';
const FAST_SEND_TIMEOUT = 1000;
const INPUT_WAVEFORM_LENGTH = 63;
type TranslateTextParams = ({
text: ApiFormattedText[];
} | {
chat: ApiChat;
messageIds: number[];
}) & {
toLanguageCode: string;
};
let onUpdate: OnApiUpdate;
export function init(_onUpdate: OnApiUpdate) {
@ -1560,3 +1572,38 @@ export async function transcribeAudio({
return result.transcriptionId.toString();
}
export async function translateText(params: TranslateTextParams) {
let result;
const isMessageTranslation = 'chat' in params;
if (isMessageTranslation) {
const { chat, messageIds, toLanguageCode } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({
peer: buildInputPeer(chat.id, chat.accessHash),
id: messageIds,
toLang: toLanguageCode,
}));
} else {
const { text, toLanguageCode } = params;
result = await invokeRequest(new GramJs.messages.TranslateText({
text: text.map((t) => buildInputTextWithEntities(t)),
toLang: toLanguageCode,
}));
}
if (!result) return undefined;
const formattedText = result.result.map((r) => buildApiFormattedText(r));
if (isMessageTranslation) {
onUpdate({
'@type': 'updateMessageTranslations',
chatId: params.chat.id,
messageIds: params.messageIds,
translations: formattedText,
toLanguageCode: params.toLanguageCode,
});
}
return formattedText;
}

View File

@ -601,6 +601,14 @@ export type ApiUpdateTopics = {
chatId: string;
};
export type ApiUpdateMessageTranslations = {
'@type': 'updateMessageTranslations';
chatId: string;
messageIds: number[];
translations: ApiFormattedText[];
toLanguageCode: string;
};
export type ApiUpdate = (
ApiUpdateReady | ApiUpdateSession | ApiUpdateWebAuthTokenFailed | ApiUpdateRequestUserUpdate |
ApiUpdateAuthorizationState | ApiUpdateAuthorizationError | ApiUpdateConnectionState | ApiUpdateCurrentUser |
@ -616,7 +624,7 @@ export type ApiUpdate = (
ApiUpdateFavoriteStickers | ApiUpdateStickerSet | ApiUpdateStickerSets | ApiUpdateStickerSetsOrder |
ApiUpdateRecentStickers | ApiUpdateSavedGifs | ApiUpdateNewScheduledMessage | ApiUpdateMoveStickerSetToTop |
ApiUpdateScheduledMessageSendSucceeded | ApiUpdateScheduledMessage |
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages |
ApiUpdateDeleteScheduledMessages | ApiUpdateResetMessages | ApiUpdateMessageTranslations |
ApiUpdateTwoFaError | ApiUpdateTwoFaStateWaitCode | ApiUpdateWebViewResultSent |
ApiUpdateNotifySettings | ApiUpdateNotifyExceptions | ApiUpdatePeerBlocked | ApiUpdatePrivacy |
ApiUpdateServerTimeOffset | ApiUpdateShowInvite | ApiUpdateMessageReactions |

View File

@ -28,6 +28,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';
// eslint-disable-next-line import/no-cycle
export { default as LeftSearch } from '../components/left/search/LeftSearch';

View File

@ -1,15 +1,17 @@
import React, { memo, useMemo, useRef } from '../../lib/teact/teact';
import type { ApiMessage } from '../../api/types';
import type { ApiFormattedText, ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import { ApiMessageEntityTypes } from '../../api/types';
import trimText from '../../util/trimText';
import { getMessageText } from '../../global/helpers';
import { getMessageText, stripCustomEmoji } from '../../global/helpers';
import { renderTextWithEntities } from './helpers/renderTextWithEntities';
interface OwnProps {
message: ApiMessage;
translatedText?: ApiFormattedText;
isForAnimation?: boolean;
emojiSize?: number;
highlight?: string;
isSimple?: boolean;
@ -25,6 +27,8 @@ const MIN_CUSTOM_EMOJIS_FOR_SHARED_CANVAS = 3;
function MessageText({
message,
translatedText,
isForAnimation,
emojiSize,
highlight,
isSimple,
@ -40,7 +44,12 @@ function MessageText({
// eslint-disable-next-line no-null/no-null
const sharedCanvasHqRef = useRef<HTMLCanvasElement>(null);
const { text, entities } = message.content.text || {};
const formattedText = translatedText || message.content.text || undefined;
const adaptedFormattedText = isForAnimation && formattedText ? stripCustomEmoji(formattedText) : formattedText;
const { text, entities } = adaptedFormattedText || {};
const withSharedCanvas = useMemo(() => {
const hasSpoilers = entities?.some((e) => e.type === ApiMessageEntityTypes.Spoiler);
if (hasSpoilers) {

View File

@ -23,7 +23,6 @@ export type TextFilter = (
'simple_markdown' | 'simple_markdown_html'
);
const RE_LETTER_OR_DIGIT = /^[\d\wа-яё]$/i;
const SIMPLE_MARKDOWN_REGEX = /(\*\*|__).+?\1/g;
export default function renderText(
@ -186,8 +185,7 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined): Tex
const lowerCaseText = part.toLowerCase();
const queryPosition = lowerCaseText.indexOf(highlight.toLowerCase());
const nextSymbol = lowerCaseText[queryPosition + highlight.length];
if (queryPosition < 0 || (nextSymbol && nextSymbol.match(RE_LETTER_OR_DIGIT))) {
if (queryPosition < 0) {
result.push(part);
return result;
}
@ -200,7 +198,6 @@ function addHighlight(textParts: TextPart[], highlight: string | undefined): Tex
</span>,
);
newParts.push(part.substring(queryPosition + highlight.length));
return [...result, ...newParts];
}, []);
}

View File

@ -298,6 +298,10 @@ const LeftColumn: FC<StateProps> = ({
case SettingsScreens.CustomEmoji:
setSettingsScreen(SettingsScreens.Stickers);
return;
case SettingsScreens.DoNotTranslate:
setSettingsScreen(SettingsScreens.Language);
return;
default:
break;
}

View File

@ -306,6 +306,10 @@
.Radio:last-child {
margin-bottom: 0;
}
.Checkbox {
margin-left: 0;
}
}
.Radio + .Radio,

View File

@ -29,6 +29,7 @@ import SettingsQuickReaction from './SettingsQuickReaction';
import SettingsPasscode from './passcode/SettingsPasscode';
import SettingsStickers from './SettingsStickers';
import SettingsCustomEmoji from './SettingsCustomEmoji';
import SettingsDoNotTranslate from './SettingsDoNotTranslate';
import SettingsExperimental from './SettingsExperimental';
import './Settings.scss';
@ -249,7 +250,15 @@ const Settings: FC<OwnProps> = ({
);
case SettingsScreens.Language:
return (
<SettingsLanguage isActive={isScreenActive} onReset={handleReset} />
<SettingsLanguage
isActive={isScreenActive || screen === SettingsScreens.DoNotTranslate}
onReset={handleReset}
onScreenSelect={onScreenSelect}
/>
);
case SettingsScreens.DoNotTranslate:
return (
<SettingsDoNotTranslate isActive={isScreenActive} onReset={handleReset} />
);
case SettingsScreens.Stickers:
return (

View File

@ -0,0 +1,20 @@
@import "../../../styles/mixins";
.root, .item {
display: flex;
flex-direction: column;
}
.item {
overflow: hidden;
min-height: 25rem;
}
.checkbox {
margin: 0;
}
.languages {
overflow-y: auto;
@include overflow-y-overlay();
}

View File

@ -0,0 +1,183 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ISettings } from '../../../types';
import type { IRadioOption } from '../../ui/CheckboxGroup';
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../../config';
import { partition, unique } from '../../../util/iteratees';
import buildClassName from '../../../util/buildClassName';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps';
import useLang from '../../../hooks/useLang';
import Checkbox from '../../ui/Checkbox';
import InputText from '../../ui/InputText';
import styles from './SettingsDoNotTranslate.module.scss';
// https://fasttext.cc/docs/en/language-identification.html
const LOCAL_SUPPORTED_DETECTION_LANGUAGES = [
'af', 'als', 'am', 'an', 'ar', 'arz', 'as', 'ast', 'av', 'az',
'azb', 'ba', 'bar', 'bcl', 'be', 'bg', 'bh', 'bn', 'bo', 'bpy',
'br', 'bs', 'bxr', 'ca', 'cbk', 'ce', 'ceb', 'ckb', 'co', 'cs',
'cv', 'cy', 'da', 'de', 'diq', 'dsb', 'dty', 'dv', 'el', 'eml',
'en', 'eo', 'es', 'et', 'eu', 'fa', 'fi', 'fr', 'frr', 'fy',
'ga', 'gd', 'gl', 'gn', 'gom', 'gu', 'gv', 'he', 'hi', 'hif',
'hr', 'hsb', 'ht', 'hu', 'hy', 'ia', 'id', 'ie', 'ilo', 'io',
'is', 'it', 'ja', 'jbo', 'jv', 'ka', 'kk', 'km', 'kn', 'ko',
'krc', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lez', 'li', 'lmo',
'lo', 'lrc', 'lt', 'lv', 'mai', 'mg', 'mhr', 'min', 'mk', 'ml',
'mn', 'mr', 'mrj', 'ms', 'mt', 'mwl', 'my', 'myv', 'mzn', 'nah',
'nap', 'nds', 'ne', 'new', 'nl', 'nn', 'no', 'oc', 'or', 'os',
'pa', 'pam', 'pfl', 'pl', 'pms', 'pnb', 'ps', 'pt', 'qu', 'rm',
'ro', 'ru', 'rue', 'sa', 'sah', 'sc', 'scn', 'sco', 'sd', 'sh',
'si', 'sk', 'sl', 'so', 'sq', 'sr', 'su', 'sv', 'sw', 'ta', 'te',
'tg', 'th', 'tk', 'tl', 'tr', 'tt', 'tyv', 'ug', 'uk', 'ur', 'uz',
'vec', 'vep', 'vi', 'vls', 'vo', 'wa', 'war', 'wuu', 'xal', 'xmf',
'yi', 'yo', 'yue', 'zh',
];
const SUPPORTED_LANGUAGES = SUPPORTED_TRANSLATION_LANGUAGES.filter((lang: string) => (
LOCAL_SUPPORTED_DETECTION_LANGUAGES.includes(lang)
));
type OwnProps = {
isActive?: boolean;
onReset: () => void;
};
type StateProps = Pick<ISettings, 'language' | 'doNotTranslate'>;
const SettingsDoNotTranslate: FC<OwnProps & StateProps> = ({
isActive,
language,
doNotTranslate,
onReset,
}) => {
const { setSettingOption } = getActions();
const lang = useLang();
const [displayedOptions, setDisplayedOptions] = useState<IRadioOption[]>([]);
const [search, setSearch] = useState('');
const options: IRadioOption[] = useMemo(() => {
return SUPPORTED_LANGUAGES.map((langCode: string) => {
const translatedNames = new Intl.DisplayNames([language], { type: 'language' });
const translatedName = translatedNames.of(langCode)!;
const originalNames = new Intl.DisplayNames([langCode], { type: 'language' });
const originalName = originalNames.of(langCode)!;
return {
langCode,
translatedName,
originalName,
};
}).map(({ langCode, translatedName, originalName }) => ({
label: translatedName,
subLabel: originalName,
value: langCode,
disabled: langCode === language,
}));
}, [language]);
const allSelected = useMemo(() => {
return unique([...doNotTranslate, language]);
}, [doNotTranslate, language]);
useEffect(() => {
if (!isActive) setSearch('');
}, [isActive]);
useEffectWithPrevDeps(([prevIsActive]) => {
if (prevIsActive === isActive) return;
if (isActive && displayedOptions.length) return;
const [selected, unselected] = partition(options, (option) => allSelected.includes(option.value));
const current = selected.find((option) => option.value === language);
const selectedFiltered = selected.filter((option) => option.value !== language);
setDisplayedOptions([current!, ...selectedFiltered, ...unselected]);
}, [isActive, allSelected, displayedOptions.length, language, options]);
const handleChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const { value, checked } = event.currentTarget;
let newDoNotTranslate: string[];
if (checked) {
newDoNotTranslate = unique([...doNotTranslate, value]);
} else {
newDoNotTranslate = doNotTranslate.filter((v) => v !== value);
}
setSettingOption({
doNotTranslate: newDoNotTranslate,
});
}, [doNotTranslate, setSettingOption]);
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}, []);
const filteredDisplayedOptions = useMemo(() => {
if (!search.trim()) {
return displayedOptions;
}
return displayedOptions.filter((option) => (
option.label.toLowerCase().includes(search.toLowerCase())
|| option.subLabel?.toLowerCase().includes(search.toLowerCase())
|| option.value.toLowerCase().includes(search.toLowerCase())
));
}, [displayedOptions, search]);
useHistoryBack({
isActive,
onBack: onReset,
});
return (
<div className={buildClassName(styles.root, 'settings-content custom-scroll')}>
<div className={buildClassName(styles.item, 'settings-item')}>
<InputText
key="search"
value={search}
onChange={handleSearch}
placeholder={lang('Search')}
teactExperimentControlled
/>
<div className={buildClassName(styles.languages, 'radio-group custom-scroll')}>
{filteredDisplayedOptions.map((option) => (
<Checkbox
className={styles.checkbox}
label={option.label}
subLabel={option.subLabel}
checked={allSelected.includes(option.value)}
value={option.value}
disabled={option.disabled}
key={option.value}
onChange={handleChange}
/>
))}
</div>
</div>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
language, doNotTranslate,
} = global.settings.byKey;
return {
language,
doNotTranslate,
};
},
)(SettingsDoNotTranslate));

View File

@ -97,6 +97,8 @@ const SettingsHeader: FC<OwnProps> = ({
return <h3>{lang('PrivacySettings')}</h3>;
case SettingsScreens.Language:
return <h3>{lang('Language')}</h3>;
case SettingsScreens.DoNotTranslate:
return <h3>{lang('DoNotTranslate')}</h3>;
case SettingsScreens.Stickers:
return <h3>{lang('StickersName')}</h3>;
case SettingsScreens.Experimental:

View File

@ -1,31 +1,44 @@
import type { FC } from '../../../lib/teact/teact';
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import { SettingsScreens } from '../../../types';
import type { ISettings, LangCode } from '../../../types';
import type { ApiLanguage } from '../../../api/types';
import { setLanguage } from '../../../util/langProvider';
import { unique } from '../../../util/iteratees';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import useLang from '../../../hooks/useLang';
import RadioGroup from '../../ui/RadioGroup';
import Loading from '../../ui/Loading';
import useFlag from '../../../hooks/useFlag';
import useHistoryBack from '../../../hooks/useHistoryBack';
import Checkbox from '../../ui/Checkbox';
import ListItem from '../../ui/ListItem';
type OwnProps = {
isActive?: boolean;
onReset: () => void;
onScreenSelect: (screen: SettingsScreens) => void;
};
type StateProps = Pick<ISettings, 'languages' | 'language'>;
type StateProps = {
lastSyncTime?: number;
} & Pick<ISettings, 'languages' | 'language' | 'canTranslate' | 'doNotTranslate'>;
const SettingsLanguage: FC<OwnProps & StateProps> = ({
isActive,
onReset,
languages,
language,
canTranslate,
doNotTranslate,
lastSyncTime,
onScreenSelect,
onReset,
}) => {
const {
loadLanguages,
@ -36,10 +49,13 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
const [selectedLanguage, setSelectedLanguage] = useState<string>(language);
const [isLoading, markIsLoading, unmarkIsLoading] = useFlag();
// TODO Throttle
const lang = useLang();
useEffect(() => {
loadLanguages();
}, [loadLanguages]);
if (lastSyncTime && !languages?.length) {
loadLanguages();
}
}, [languages, lastSyncTime, loadLanguages]);
const handleChange = useCallback((langCode: string) => {
setSelectedLanguage(langCode);
@ -58,24 +74,65 @@ const SettingsLanguage: FC<OwnProps & StateProps> = ({
return languages ? buildOptions(languages) : undefined;
}, [languages]);
const handleShouldTranslateChange = useCallback((newValue: boolean) => {
setSettingOption({ canTranslate: newValue });
}, [setSettingOption]);
const doNotTranslateText = useMemo(() => {
const allDoNotTranslateLanguages = unique([...doNotTranslate, language]);
// Do not translate current language
if (allDoNotTranslateLanguages.length === 1) {
if (!languages) {
return lang('Loading');
}
return languages.find(({ langCode }) => langCode === language)?.nativeName;
}
return lang('Languages', allDoNotTranslateLanguages.length);
}, [doNotTranslate, lang, language, languages]);
const handleDoNotSelectOpen = useCallback(() => {
onScreenSelect(SettingsScreens.DoNotTranslate);
}, [onScreenSelect]);
useHistoryBack({
isActive,
onBack: onReset,
});
return (
<div className="settings-content settings-item settings-language custom-scroll settings-item--first">
{options ? (
<RadioGroup
name="keyboard-send-settings"
options={options}
selected={selectedLanguage}
loadingOption={isLoading ? selectedLanguage : undefined}
onChange={handleChange}
<div className="settings-content settings-language custom-scroll">
<div className="settings-item">
<Checkbox
label={lang('ShowTranslateButton')}
checked={canTranslate}
onCheck={handleShouldTranslateChange}
/>
) : (
<Loading />
)}
{canTranslate && (
<ListItem
onClick={handleDoNotSelectOpen}
>
{lang('DoNotTranslate')}
<span className="settings-item__current-value">{doNotTranslateText}</span>
</ListItem>
)}
<p className="settings-item-description mb-0 mt-1">
{lang('lng_translate_settings_about')}
</p>
</div>
<div className="settings-item">
{options ? (
<RadioGroup
name="language-settings"
options={options}
selected={selectedLanguage}
loadingOption={isLoading ? selectedLanguage : undefined}
onChange={handleChange}
/>
) : (
<Loading />
)}
</div>
</div>
);
};
@ -95,9 +152,16 @@ function buildOptions(languages: ApiLanguage[]) {
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const {
language, languages, canTranslate, doNotTranslate,
} = global.settings.byKey;
return {
languages: global.settings.byKey.languages,
language: global.settings.byKey.language,
lastSyncTime: global.lastSyncTime,
languages,
language,
canTranslate,
doNotTranslate,
};
},
)(SettingsLanguage));

View File

@ -0,0 +1,17 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } 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 memo(MessageLanguageModalAsync);

View File

@ -0,0 +1,30 @@
@import "../../styles/mixins";
.root {
:global(.modal-content) {
min-height: min(75vh, 32rem);
display: flex;
flex-direction: column;
}
:global(.matching-text-highlight) {
color: var(--color-primary);
}
}
.list-item {
text-align: left;
&[dir="rtl"] {
text-align: right;
}
}
.title, .subtitle {
text-align: inherit !important;
}
.languages {
overflow-y: auto;
@include overflow-y-overlay();
}

View File

@ -0,0 +1,147 @@
import React, {
memo, useCallback, useEffect, useMemo, useState,
} from '../../lib/teact/teact';
import { getActions, withGlobal } from '../../global';
import type { FC } from '../../lib/teact/teact';
import { selectLanguageCode, selectRequestedTranslationLanguage, selectTabState } from '../../global/selectors';
import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../config';
import buildClassName from '../../util/buildClassName';
import renderText from '../common/helpers/renderText';
import useLang from '../../hooks/useLang';
import Modal from '../ui/Modal';
import ListItem from '../ui/ListItem';
import InputText from '../ui/InputText';
import styles from './MessageLanguageModal.module.scss';
type LanguageItem = {
langCode: string;
translatedName: string;
originalName: string;
};
export type OwnProps = {
isOpen?: boolean;
};
type StateProps = {
chatId?: string;
messageId?: number;
activeTranslationLanguage?: string;
currentLanguageCode: string;
};
const MessageLanguageModal: FC<OwnProps & StateProps> = ({
isOpen,
chatId,
messageId,
activeTranslationLanguage,
currentLanguageCode,
}) => {
const { requestMessageTranslation, closeMessageLanguageModal } = getActions();
const [search, setSearch] = useState('');
const lang = useLang();
const handleSelect = useCallback((toLanguageCode: string) => {
if (!chatId || !messageId) return;
requestMessageTranslation({ chatId, id: messageId, toLanguageCode });
closeMessageLanguageModal();
}, [chatId, closeMessageLanguageModal, messageId, requestMessageTranslation]);
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}, []);
const translateLanguages = useMemo(() => SUPPORTED_TRANSLATION_LANGUAGES.map((langCode: string) => {
const translatedNames = new Intl.DisplayNames([currentLanguageCode], { type: 'language' });
const translatedName = translatedNames.of(langCode)!;
const originalNames = new Intl.DisplayNames([langCode], { type: 'language' });
const originalName = originalNames.of(langCode)!;
return {
langCode,
translatedName,
originalName,
} satisfies LanguageItem;
}), [currentLanguageCode]);
useEffect(() => {
if (!isOpen) setSearch('');
}, [isOpen]);
const filteredDisplayedLanguages = useMemo(() => {
if (!search.trim()) {
return translateLanguages;
}
return translateLanguages.filter(({ langCode, translatedName, originalName }) => (
translatedName.toLowerCase().includes(search.toLowerCase())
|| originalName.toLowerCase().includes(search.toLowerCase())
|| langCode.toLowerCase().includes(search.toLowerCase())
));
}, [translateLanguages, search]);
return (
<Modal
className={styles.root}
isSlim
isOpen={isOpen}
hasCloseButton
title={lang('Language')}
onClose={closeMessageLanguageModal}
>
<InputText
key="search"
value={search}
onChange={handleSearch}
placeholder={lang('Search')}
teactExperimentControlled
/>
<div className={buildClassName(styles.languages, 'custom-scroll')}>
{filteredDisplayedLanguages.map(({ langCode, originalName, translatedName }) => (
<ListItem
key={langCode}
className={styles.listItem}
secondaryIcon={activeTranslationLanguage === langCode ? 'check' : undefined}
disabled={activeTranslationLanguage === langCode}
multiline
narrow
// eslint-disable-next-line react/jsx-no-bind
onClick={() => handleSelect(langCode)}
>
<span className={buildClassName('title', styles.title)}>
{renderText(originalName, ['highlight'], { highlight: search })}
</span>
<span className={buildClassName('subtitle', styles.subtitle)}>
{renderText(translatedName, ['highlight'], { highlight: search })}
</span>
</ListItem>
))}
</div>
</Modal>
);
};
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { chatId, messageId } = selectTabState(global).messageLanguageModal || {};
const currentLanguageCode = selectLanguageCode(global);
const activeTranslationLanguage = chatId && messageId
? selectRequestedTranslationLanguage(global, chatId, messageId) : undefined;
return {
chatId,
messageId,
activeTranslationLanguage,
currentLanguageCode,
};
},
)(MessageLanguageModal));

View File

@ -77,6 +77,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 './MiddleColumn.scss';
import styles from './MiddleColumn.module.scss';
@ -111,6 +112,7 @@ type StateProps = {
isSeenByModalOpen: boolean;
isReactorListModalOpen: boolean;
isGiftPremiumModalOpen?: boolean;
isMessageLanguageModalOpen?: boolean;
animationLevel: AnimationLevel;
shouldSkipHistoryAnimations?: boolean;
currentTransitionKey: number;
@ -156,6 +158,7 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
isSeenByModalOpen,
isReactorListModalOpen,
isGiftPremiumModalOpen,
isMessageLanguageModalOpen,
animationLevel,
shouldSkipHistoryAnimations,
currentTransitionKey,
@ -551,6 +554,7 @@ const MiddleColumn: FC<OwnProps & StateProps> = ({
/>
<SeenByModal isOpen={isSeenByModalOpen} />
<ReactorListModal isOpen={isReactorListModalOpen} />
<MessageLanguageModal isOpen={isMessageLanguageModalOpen} />
</div>
</Transition>
@ -596,6 +600,7 @@ export default memo(withGlobal<OwnProps>(
const {
messageLists, isLeftColumnShown, activeEmojiInteractions,
seenByModal, giftPremiumModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations,
messageLanguageModal,
} = selectTabState(global);
const currentMessageList = selectCurrentMessageList(global);
const { chats: { listIds }, lastSyncTime } = global;
@ -613,6 +618,7 @@ export default memo(withGlobal<OwnProps>(
isSeenByModalOpen: Boolean(seenByModal),
isReactorListModalOpen: Boolean(reactorModal),
isGiftPremiumModalOpen: giftPremiumModal?.isOpen,
isMessageLanguageModalOpen: Boolean(messageLanguageModal),
animationLevel: global.settings.byKey.animationLevel,
currentTransitionKey: Math.max(0, messageLists.length - 1),
activeEmojiInteractions,

View File

@ -20,6 +20,8 @@ import {
selectIsMessageProtected,
selectIsPremiumPurchaseBlocked,
selectMessageCustomEmojiSets,
selectMessageTranslations,
selectRequestedTranslationLanguage,
selectStickerSet,
} from '../../../global/selectors';
import {
@ -27,6 +29,7 @@ import {
isChatGroup, isOwnMessage, areReactionsEmpty, isUserId, isMessageLocal, getMessageVideo, getChatMessageLink,
} from '../../../global/helpers';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../../../config';
import { IS_TRANSLATION_SUPPORTED } from '../../../util/environment';
import buildClassName from '../../../util/buildClassName';
import { copyTextToClipboard } from '../../../util/clipboard';
@ -49,6 +52,7 @@ export type OwnProps = {
anchor: IAnchorPosition;
messageListType: MessageListType;
noReplies?: boolean;
detectedLanguage?: string;
onClose: () => void;
onCloseAnimationEnd: () => void;
repliesThreadInfo?: ApiThreadInfo;
@ -74,6 +78,9 @@ type StateProps = {
canFaveSticker?: boolean;
canUnfaveSticker?: boolean;
canCopy?: boolean;
canTranslate?: boolean;
canShowOriginal?: boolean;
canSelectLanguage?: boolean;
isPrivate?: boolean;
isCurrentUserPremium?: boolean;
hasFullInfo?: boolean;
@ -101,8 +108,6 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
customEmojiSets,
album,
anchor,
onClose,
onCloseAnimationEnd,
noOptions,
canSendNow,
hasFullInfo,
@ -135,7 +140,12 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
noReplies,
canShowSeenBy,
canScheduleUntilOnline,
canTranslate,
canShowOriginal,
canSelectLanguage,
threadId,
onClose,
onCloseAnimationEnd,
}) => {
const {
openChat,
@ -161,6 +171,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
cancelPollVote,
closePoll,
toggleReaction,
requestMessageTranslation,
showOriginalMessage,
openMessageLanguageModal,
} = getActions();
const lang = useLang();
@ -393,6 +406,30 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
closeMenu();
}, [closeMenu, message, toggleReaction]);
const handleTranslate = useCallback(() => {
requestMessageTranslation({
chatId: message.chatId,
id: message.id,
});
closeMenu();
}, [closeMenu, message, requestMessageTranslation]);
const handleShowOriginal = useCallback(() => {
showOriginalMessage({
chatId: message.chatId,
id: message.id,
});
closeMenu();
}, [closeMenu, message, showOriginalMessage]);
const handleSelectLanguage = useCallback(() => {
openMessageLanguageModal({
chatId: message.chatId,
id: message.id,
});
closeMenu();
}, [closeMenu, message.chatId, message.id, openMessageLanguageModal]);
const reportMessageIds = useMemo(() => (album ? album.messages : [message]).map(({ id }) => id), [album, message]);
if (noOptions) {
@ -438,6 +475,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
canRevote={canRevote}
canClosePoll={canClosePoll}
canShowSeenBy={canShowSeenBy}
canTranslate={canTranslate}
canShowOriginal={canShowOriginal}
canSelectLanguage={canSelectLanguage}
hasCustomEmoji={hasCustomEmoji}
customEmojiSets={customEmojiSets}
isDownloading={isDownloading}
@ -467,6 +507,9 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
onShowSeenBy={handleOpenSeenByModal}
onToggleReaction={handleToggleReaction}
onShowReactors={handleOpenReactorListModal}
onTranslate={handleTranslate}
onShowOriginal={handleShowOriginal}
onSelectLanguage={handleSelectLanguage}
/>
<DeleteMessageModal
isOpen={isDeleteModalOpen}
@ -499,7 +542,7 @@ const ContextMenuContainer: FC<OwnProps & StateProps> = ({
};
export default memo(withGlobal<OwnProps>(
(global, { message, messageListType }): StateProps => {
(global, { message, messageListType, detectedLanguage }): StateProps => {
const { threadId } = selectCurrentMessageList(global) || {};
const activeDownloads = selectActiveDownloadIds(global, message.chatId);
const chat = selectChat(global, message.chatId);
@ -524,6 +567,7 @@ export default memo(withGlobal<OwnProps>(
canClosePoll,
} = (threadId && selectAllowedMessageActions(global, message, threadId)) || {};
const isOwn = isOwnMessage(message);
const isPinned = messageListType === 'pinned';
const isScheduled = messageListType === 'scheduled';
const isChannel = chat && isChatChannel(chat);
@ -532,7 +576,7 @@ export default memo(withGlobal<OwnProps>(
&& seenByMaxChatMembers
&& seenByExpiresAt
&& isChatGroup(chat)
&& isOwnMessage(message)
&& isOwn
&& !isScheduled
&& chat.membersCount
&& chat.membersCount <= seenByMaxChatMembers
@ -550,6 +594,18 @@ export default memo(withGlobal<OwnProps>(
const customEmojiSets = customEmojiSetsNotFiltered?.every<ApiStickerSet>(Boolean)
? customEmojiSetsNotFiltered : undefined;
const translationRequestLanguage = selectRequestedTranslationLanguage(global, message.chatId, message.id);
const hasTranslation = translationRequestLanguage
? Boolean(selectMessageTranslations(global, message.chatId, translationRequestLanguage)[message.id]?.text)
: undefined;
const { canTranslate: isTranslationEnabled, language, doNotTranslate } = global.settings.byKey;
const canTranslateLanguage = !detectedLanguage
|| (!doNotTranslate.includes(detectedLanguage) && language !== detectedLanguage);
const canTranslate = IS_TRANSLATION_SUPPORTED && isTranslationEnabled && message.content.text
&& canTranslateLanguage && !isLocal && !isScheduled && !isAction && !hasTranslation && !message.emojiOnlyCount;
return {
availableReactions: global.availableReactions,
noOptions,
@ -585,6 +641,9 @@ export default memo(withGlobal<OwnProps>(
customEmojiSets,
canScheduleUntilOnline: selectCanScheduleUntilOnline(global, message.chatId),
threadId,
canTranslate,
canShowOriginal: hasTranslation,
canSelectLanguage: hasTranslation,
};
},
)(ContextMenuContainer));

View File

@ -9,7 +9,9 @@ import React, {
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { ActiveEmojiInteraction, ActiveReaction, MessageListType } from '../../../global/types';
import type {
ActiveEmojiInteraction, ActiveReaction, ChatTranslatedMessages, MessageListType,
} from '../../../global/types';
import type {
ApiMessage,
ApiMessageOutgoingStatus,
@ -63,6 +65,8 @@ import {
selectIsChatProtected,
selectTopicFromMessage,
selectTabState,
selectChatTranslations,
selectRequestedTranslationLanguage,
} from '../../../global/selectors';
import {
getMessageContent,
@ -113,6 +117,9 @@ import useInnerHandlers from './hooks/useInnerHandlers';
import useAppLayout from '../../../hooks/useAppLayout';
import useResizeObserver from '../../../hooks/useResizeObserver';
import useThrottledCallback from '../../../hooks/useThrottledCallback';
import useMessageTranslation from './hooks/useMessageTranslation';
import usePrevious from '../../../hooks/usePrevious';
import useTextLanguage from '../../../hooks/useTextLanguage';
import Button from '../../ui/Button';
import Avatar from '../../common/Avatar';
@ -233,6 +240,9 @@ type StateProps = {
senderAdminMember?: ApiChatMember;
messageTopic?: ApiTopic;
hasTopicChip?: boolean;
chatTranslations?: ChatTranslatedMessages;
areTranslationsEnabled?: boolean;
requestedTranslationLanguage?: string;
};
type MetaPosition =
@ -329,6 +339,9 @@ const Message: FC<OwnProps & StateProps> = ({
senderAdminMember,
messageTopic,
hasTopicChip,
chatTranslations,
areTranslationsEnabled,
requestedTranslationLanguage,
}) => {
const {
toggleMessageSelection,
@ -468,6 +481,7 @@ const Message: FC<OwnProps & StateProps> = ({
handleAudioPlay,
handleAlbumMediaClick,
handleMetaClick,
handleTranslationClick,
handleOpenThread,
handleReadMedia,
handleCancelUpload,
@ -537,6 +551,17 @@ const Message: FC<OwnProps & StateProps> = ({
text, photo, video, audio, voice, document, sticker, contact, poll, webPage, invoice, location, action, game,
} = getMessageContent(message);
const { result: detectedLanguage } = useTextLanguage(areTranslationsEnabled ? text?.text : undefined);
const { isPending: isTranslationPending, translatedText } = useMessageTranslation(
chatTranslations, chatId, messageId, requestedTranslationLanguage,
);
// Used to display previous result while new one is loading
const previousTranslatedText = usePrevious(translatedText, true);
const currentText = isTranslationPending ? (previousTranslatedText || text) : translatedText;
const currentTranslatedText = translatedText || previousTranslatedText;
const { phoneCall } = action || {};
const withCommentButton = repliesThreadInfo && !isInDocumentGroupNotLast && messageListType === 'thread'
@ -663,13 +688,15 @@ const Message: FC<OwnProps & StateProps> = ({
}
if (width) {
calculatedWidth = Math.max(getMinMediaWidth(Boolean(text), withCommentButton), width);
calculatedWidth = Math.max(getMinMediaWidth(Boolean(currentText), withCommentButton), width);
if (invoice?.extendedMedia && calculatedWidth - width > NO_MEDIA_CORNERS_THRESHOLD) {
noMediaCorners = true;
}
}
} else if (albumLayout) {
calculatedWidth = Math.max(getMinMediaWidth(Boolean(text), withCommentButton), albumLayout.containerStyle.width);
calculatedWidth = Math.max(
getMinMediaWidth(Boolean(currentText), withCommentButton), albumLayout.containerStyle.width,
);
if (calculatedWidth - albumLayout.containerStyle.width > NO_MEDIA_CORNERS_THRESHOLD) {
noMediaCorners = true;
}
@ -707,6 +734,22 @@ const Message: FC<OwnProps & StateProps> = ({
);
}
function renderMessageText(isForAnimation?: boolean) {
return (
<MessageText
message={message}
translatedText={requestedTranslationLanguage ? currentTranslatedText : undefined}
isForAnimation={isForAnimation}
emojiSize={emojiSize}
highlight={highlight}
isProtected={isProtected}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
withTranslucentThumbs={isCustomShape}
/>
);
}
function renderReactionsAndMeta() {
const meta = (
<MessageMeta
@ -717,7 +760,9 @@ const Message: FC<OwnProps & StateProps> = ({
signature={signature}
withReactionOffset={reactionsPosition === 'inside'}
availableReactions={availableReactions}
isTranslated={Boolean(requestedTranslationLanguage ? currentTranslatedText : undefined)}
onClick={handleMetaClick}
onTranslationClick={handleTranslationClick}
onOpenThread={handleOpenThread}
/>
);
@ -749,6 +794,7 @@ const Message: FC<OwnProps & StateProps> = ({
const hasCustomAppendix = isLastInGroup && !hasText && !asForwarded && !hasThread;
const textContentClass = buildClassName(
'text-content',
'clearfix',
metaPosition === 'in-text' && 'with-meta',
outgoingStatus && 'with-outgoing-icon',
);
@ -954,15 +1000,14 @@ const Message: FC<OwnProps & StateProps> = ({
{!hasAnimatedEmoji && hasText && (
<div className={textContentClass} dir="auto">
<MessageText
message={message}
emojiSize={emojiSize}
highlight={highlight}
isProtected={isProtected}
observeIntersectionForLoading={observeIntersectionForLoading}
observeIntersectionForPlaying={observeIntersectionForPlaying}
withTranslucentThumbs={isCustomShape}
/>
{renderMessageText()}
{isTranslationPending && (
<div className="translation-animation">
<div className="text-loading">
{renderMessageText(true)}
</div>
</div>
)}
{metaPosition === 'in-text' && renderReactionsAndMeta()}
</div>
)}
@ -1208,6 +1253,7 @@ const Message: FC<OwnProps & StateProps> = ({
onCloseAnimationEnd={handleContextMenuHide}
repliesThreadInfo={repliesThreadInfo}
noReplies={noReplies}
detectedLanguage={detectedLanguage}
/>
)}
</div>
@ -1300,6 +1346,8 @@ export default memo(withGlobal<OwnProps>(
: undefined;
const isLocation = Boolean(getMessageLocation(message));
const chatTranslations = selectChatTranslations(global, chatId);
const requestedTranslationLanguage = selectRequestedTranslationLanguage(global, chatId, message.id);
return {
theme: selectTheme(global),
@ -1355,6 +1403,9 @@ export default memo(withGlobal<OwnProps>(
messageTopic,
genericEffects: global.genericEmojiEffects,
hasTopicChip,
chatTranslations,
areTranslationsEnabled: global.settings.byKey.canTranslate,
requestedTranslationLanguage,
...((canShowSender || isLocation) && { sender }),
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
...(typeof uploadProgress === 'number' && { uploadProgress }),

View File

@ -61,6 +61,9 @@ type OwnProps = {
canCopy?: boolean;
canCopyLink?: boolean;
canSelect?: boolean;
canTranslate?: boolean;
canShowOriginal?: boolean;
canSelectLanguage?: boolean;
isPrivate?: boolean;
isCurrentUserPremium?: boolean;
canDownload?: boolean;
@ -99,6 +102,9 @@ type OwnProps = {
onShowReactors?: () => void;
onAboutAds?: () => void;
onSponsoredHide?: () => void;
onTranslate?: () => void;
onShowOriginal?: () => void;
onSelectLanguage?: () => void;
onToggleReaction?: (reaction: ApiReaction) => void;
};
@ -135,6 +141,9 @@ const MessageContextMenu: FC<OwnProps> = ({
canSaveGif,
canRevote,
canClosePoll,
canTranslate,
canShowOriginal,
canSelectLanguage,
isDownloading,
repliesThreadInfo,
canShowSeenBy,
@ -170,6 +179,9 @@ const MessageContextMenu: FC<OwnProps> = ({
onCopyMessages,
onAboutAds,
onSponsoredHide,
onTranslate,
onShowOriginal,
onSelectLanguage,
}) => {
const { showNotification, openStickerSet, openCustomEmojiSets } = getActions();
// eslint-disable-next-line no-null/no-null
@ -319,6 +331,11 @@ const MessageContextMenu: FC<OwnProps> = ({
{canUnfaveSticker && (
<MenuItem icon="favorite" onClick={onUnfaveSticker}>{lang('Stickers.RemoveFromFavorites')}</MenuItem>
)}
{canTranslate && <MenuItem icon="language" onClick={onTranslate}>{lang('TranslateMessage')}</MenuItem>}
{canShowOriginal && <MenuItem icon="language" onClick={onShowOriginal}>{lang('ShowOriginalButton')}</MenuItem>}
{canSelectLanguage && (
<MenuItem icon="web" onClick={onSelectLanguage}>{lang('lng_settings_change_lang')}</MenuItem>
)}
{canCopy && copyOptions.map((option) => (
<MenuItem key={option.label} icon={option.icon} onClick={option.handler}>{lang(option.label)}</MenuItem>
))}

View File

@ -17,7 +17,8 @@
.message-imported,
.message-signature,
.message-views,
.message-replies {
.message-replies,
.message-translated {
font-size: 0.75rem;
white-space: nowrap;
}
@ -36,6 +37,10 @@
margin-inline-start: 0.1875rem;
}
.message-translated {
margin-inline-end: 0.25rem;
}
.message-imported,
.message-signature {
overflow: hidden;

View File

@ -27,8 +27,10 @@ type OwnProps = {
availableReactions?: ApiAvailableReaction[];
noReplies?: boolean;
repliesThreadInfo?: ApiThreadInfo;
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
onOpenThread: () => void;
isTranslated?: boolean;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onTranslationClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onOpenThread: NoneToVoidFunction;
};
const MessageMeta: FC<OwnProps> = ({
@ -38,7 +40,9 @@ const MessageMeta: FC<OwnProps> = ({
withReactionOffset,
repliesThreadInfo,
noReplies,
isTranslated,
onClick,
onTranslationClick,
onOpenThread,
}) => {
const { showNotification } = getActions();
@ -90,6 +94,9 @@ const MessageMeta: FC<OwnProps> = ({
onClick={onClick}
data-ignore-on-paste
>
{isTranslated && (
<i className="icon-language message-translated" onClick={onTranslationClick} />
)}
{Boolean(message.views) && (
<>
<span className="message-views">

View File

@ -41,8 +41,10 @@
white-space: pre-wrap;
line-height: 1.3125;
text-align: initial;
display: flow-root;
display: block;
unicode-bidi: plaintext;
border-radius: 0.25rem;
position: relative;
}
.transcription {
@ -68,6 +70,40 @@
}
}
.translation-animation {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.text-loading {
--background-gradient-size: 20rem;
--animation-color: var(--color-skeleton-background);
background-image: linear-gradient(to right, transparent 10%, var(--animation-color) 50%, transparent 90%);
background-size: var(--background-gradient-size);
display: inline;
box-decoration-break: clone;
color: transparent;
border-radius: 0.25rem;
animation: text-loading 1.5s linear infinite;
.theme-dark & {
--animation-color: var(--color-skeleton-foreground);
}
.emoji {
opacity: 0;
}
}
.text-entity-link {
unicode-bidi: plaintext;
}
@ -735,7 +771,6 @@
}
.text-content {
margin-bottom: 1.25rem;
word-break: normal;
line-height: var(--emoji-only-size);
font-size: var(--emoji-only-size);
@ -747,7 +782,7 @@
.MessageMeta {
text-shadow: none;
bottom: 0;
bottom: -1.25rem;
right: 0;
line-height: normal;
}
@ -903,3 +938,19 @@
font-family: var(--font-family-monospace);
font-size: 0.875rem;
}
@keyframes text-loading {
0% {
background-position-x: 0;
opacity: 1;
}
50% {
opacity: 0.8;
}
100% {
background-position-x: var(--background-gradient-size);
opacity: 1;
}
}

View File

@ -29,6 +29,7 @@ export default function useInnerHandlers(
const {
openChat, showNotification, focusMessage, openMediaViewer, openAudioPlayer,
markMessagesRead, cancelSendingMessage, sendPollVote, openForwardMenu, focusMessageInComments,
openMessageLanguageModal,
} = getActions();
const {
@ -154,12 +155,18 @@ export default function useInnerHandlers(
focusMessageInComments, replyToTopMessageId,
]);
const selectWithGroupedId = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
const selectWithGroupedId = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
selectMessage(e, groupedId);
}, [selectMessage, groupedId]);
const handleTranslationClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
openMessageLanguageModal({ chatId, id: messageId });
}, [chatId, messageId, openMessageLanguageModal]);
const handleOpenThread = useCallback(() => {
openChat({
id: message.chatId,
@ -185,6 +192,7 @@ export default function useInnerHandlers(
handleAudioPlay,
handleAlbumMediaClick,
handleMetaClick: selectWithGroupedId,
handleTranslationClick,
handleOpenThread,
handleReadMedia,
handleCancelUpload,

View File

@ -0,0 +1,27 @@
import { useEffect } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ChatTranslatedMessages } from '../../../../global/types';
export default function useMessageTranslation(
chatTranslations: ChatTranslatedMessages | undefined,
chatId: string,
messageId: number,
requestedLanguageCode?: string,
) {
const { translateMessages } = getActions();
const messageTranslation = requestedLanguageCode
? chatTranslations?.byLangCode[requestedLanguageCode]?.[messageId] : undefined;
const { isPending, text } = messageTranslation || {};
useEffect(() => {
if (!text && !isPending && requestedLanguageCode) {
translateMessages({ chatId, messageIds: [messageId], toLanguageCode: requestedLanguageCode });
}
}, [chatId, text, isPending, messageId, requestedLanguageCode, translateMessages]);
return {
isPending,
translatedText: text,
};
}

View File

@ -4,9 +4,10 @@ import React, { useCallback, memo, useState } from '../../lib/teact/teact';
import Checkbox from './Checkbox';
type IRadioOption = {
export type IRadioOption = {
label: string;
subLabel?: string;
disabled?: boolean;
value: string;
};
@ -52,7 +53,7 @@ const CheckboxGroup: FC<OwnProps> = ({
subLabel={option.subLabel}
value={option.value}
checked={selected.indexOf(option.value) !== -1}
disabled={disabled}
disabled={option.disabled || disabled}
round={round}
isLoading={loadingOptions ? loadingOptions.indexOf(option.value) !== -1 : undefined}
onChange={handleChange}

View File

@ -28,6 +28,16 @@
}
}
&.slim {
.modal-dialog {
max-width: 25rem;
}
.modal-content {
max-height: min(92vh, 32rem);
}
}
.modal-container {
position: fixed;
top: 0;

View File

@ -26,6 +26,7 @@ type OwnProps = {
className?: string;
isOpen?: boolean;
header?: TeactNode;
isSlim?: boolean;
hasCloseButton?: boolean;
noBackdrop?: boolean;
noBackdropClose?: boolean;
@ -46,6 +47,7 @@ const Modal: FC<OwnProps & StateProps> = ({
title,
className,
isOpen,
isSlim,
header,
hasCloseButton,
noBackdrop,
@ -136,6 +138,7 @@ const Modal: FC<OwnProps & StateProps> = ({
className,
transitionClassNames,
noBackdrop && 'transparent-backdrop',
isSlim && 'slim',
);
return (

View File

@ -44,7 +44,7 @@ export const CUSTOM_EMOJI_PREVIEW_CACHE_DISABLED = false;
export const CUSTOM_EMOJI_PREVIEW_CACHE_NAME = 'tt-custom-emoji-preview';
export const MEDIA_CACHE_MAX_BYTES = 512 * 1024; // 512 KB
export const CUSTOM_BG_CACHE_NAME = 'tt-custom-bg';
export const LANG_CACHE_NAME = 'tt-lang-packs-v16';
export const LANG_CACHE_NAME = 'tt-lang-packs-v17';
export const ASSET_CACHE_NAME = 'tt-assets';
export const AUTODOWNLOAD_FILESIZE_MB_LIMITS = [1, 5, 10, 50, 100, 500];
export const DATA_BROADCAST_CHANNEL_NAME = 'tt-global';
@ -218,6 +218,25 @@ export const CONTENT_TYPES_WITH_PREVIEW = new Set([
export const CONTENT_NOT_SUPPORTED = 'The message is not supported on this version of Telegram.';
// Taken from https://github.com/telegramdesktop/tdesktop/blob/41d9a9fcbd0c809c60ddbd9350791b1436aff7d9/Telegram/SourceFiles/ui/boxes/choose_language_box.cpp#L28
export const SUPPORTED_TRANSLATION_LANGUAGES = [
// Official
'en', 'ar', 'be', 'ca', 'zh', 'nl', 'fr', 'de', 'id',
'it', 'ja', 'ko', 'pl', 'pt', 'ru', 'es', 'uk',
// Unnofficial
'af', 'sq', 'am', 'hy', 'az', 'eu', 'bn', 'bs', 'bg',
'ceb', 'zh-CN', 'zh-TW', 'co', 'hr', 'cs', 'da', 'eo',
'et', 'fi', 'fy', 'gl', 'ka', 'el', 'gu', 'ht', 'ha',
'haw', 'he', 'iw', 'hi', 'hmn', 'hu', 'is', 'ig', 'ga',
'jv', 'kn', 'kk', 'km', 'rw', 'ku', 'ky', 'lo', 'la',
'lv', 'lt', 'lb', 'mk', 'mg', 'ms', 'ml', 'mt', 'mi',
'mr', 'mn', 'my', 'ne', 'no', 'ny', 'or', 'ps', 'fa',
'pa', 'ro', 'sm', 'gd', 'sr', 'st', 'sn', 'sd', 'si',
'sk', 'sl', 'so', 'su', 'sw', 'sv', 'tl', 'tg', 'ta',
'tt', 'te', 'th', 'tr', 'tk', 'ur', 'ug', 'uz', 'vi',
'cy', 'xh', 'yi', 'yo', 'zu',
];
// eslint-disable-next-line max-len
export const RE_LINK_TEMPLATE = '((ftp|https?):\\/\\/)?((www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,63})\\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)';
export const RE_MENTION_TEMPLATE = '(@[\\w\\d_-]+)';

View File

@ -54,6 +54,9 @@ import {
updateTopic,
updateThreadInfo,
replaceTabThreadParam,
updateRequestedMessageTranslation,
removeRequestedMessageTranslation,
updateMessageTranslation,
} from '../../reducers';
import {
selectChat,
@ -80,7 +83,9 @@ import {
selectIsCurrentUserPremium,
selectForwardsContainVoiceMessages,
selectTabState,
selectThreadIdFromMessage, selectForwardsCanBeSentToChat,
selectThreadIdFromMessage,
selectLanguageCode,
selectForwardsCanBeSentToChat,
} from '../../selectors';
import {
debounce, onTickEnd, rafPromise,
@ -1411,6 +1416,49 @@ addActionHandler('forwardToSavedMessages', (global, actions, payload): ActionRet
actions.forwardMessages({ isSilent: true, tabId });
});
addActionHandler('requestMessageTranslation', (global, actions, payload): ActionReturnType => {
const {
chatId, id, toLanguageCode = selectLanguageCode(global), tabId = getCurrentTabId(),
} = payload;
global = updateRequestedMessageTranslation(global, chatId, id, toLanguageCode, tabId);
return global;
});
addActionHandler('showOriginalMessage', (global, actions, payload): ActionReturnType => {
const {
chatId, id, tabId = getCurrentTabId(),
} = payload;
global = removeRequestedMessageTranslation(global, chatId, id, tabId);
return global;
});
addActionHandler('translateMessages', (global, actions, payload): ActionReturnType => {
const {
chatId, messageIds, toLanguageCode = selectLanguageCode(global),
} = payload;
const chat = selectChat(global, chatId);
if (!chat) return undefined;
messageIds.forEach((id) => {
global = updateMessageTranslation(global, chatId, id, toLanguageCode, {
isPending: true,
});
});
callApi('translateText', {
chat,
messageIds,
toLanguageCode,
});
return global;
});
function countSortedIds(ids: number[], from: number, to: number) {
let count = 0;

View File

@ -27,6 +27,8 @@ import {
updateThreadUnreadFromForwardedMessage,
updateTopic,
deleteTopic,
updateMessageTranslations,
clearMessageTranslation,
} from '../../reducers';
import {
selectChatMessage,
@ -211,6 +213,10 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateReactions(global, chatId, id, message.reactions, chat, newMessage.isOutgoing, currentMessage);
}
if (message.content?.text?.text !== currentMessage?.content?.text?.text) {
global = clearMessageTranslation(global, chatId, id);
}
setGlobal(global);
break;
@ -639,6 +645,18 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
global = updateChatMessage(global, chatId, localId, { sendingState: 'messageSendingStateFailed' });
setGlobal(global);
break;
}
case 'updateMessageTranslations': {
const {
chatId, messageIds, toLanguageCode, translations,
} = update;
global = updateMessageTranslations(global, chatId, messageIds, toLanguageCode, translations);
setGlobal(global);
break;
}
}
});
@ -878,6 +896,8 @@ function deleteMessages<T extends GlobalState>(
isDeleting: true,
});
global = clearMessageTranslation(global, chatId, id);
const newLastMessage = findLastMessage(global, chatId);
if (newLastMessage) {
global = updateChatLastMessage(global, chatId, newLastMessage, true);

View File

@ -39,8 +39,17 @@ addActionHandler('apiUpdate', (global, actions, update): ActionReturnType => {
case 'updateUser': {
Object.values(global.byTabId).forEach(({ id: tabId }) => {
if (update.id === global.currentUserId && update.user.isPremium && !selectIsCurrentUserPremium(global)) {
actions.openPremiumModal({ isSuccess: true, tabId });
if (update.id === global.currentUserId && update.user.isPremium !== selectIsCurrentUserPremium(global)) {
// TODO Do not display modal if premium is bought from another device
if (update.user.isPremium) actions.openPremiumModal({ isSuccess: true, tabId });
// Reset translation cache cause premium provides additional formatting
global = {
...global,
translations: {
byChatId: {},
},
};
}
});

View File

@ -41,6 +41,7 @@ import {
selectSender,
selectChatScheduledMessages,
selectTabState,
selectRequestedTranslationLanguage,
} from '../../selectors';
import { compact, findLast } from '../../../util/iteratees';
import { getServerTime } from '../../../util/serverTime';
@ -74,7 +75,7 @@ addActionHandler('setScrollOffset', (global, actions, payload): ActionReturnType
});
addActionHandler('setReplyingToId', (global, actions, payload): ActionReturnType => {
const { messageId, tabId = getCurrentTabId() } = payload!;
const { messageId, tabId = getCurrentTabId() } = payload;
const currentMessageList = selectCurrentMessageList(global, tabId);
if (!currentMessageList) {
return undefined;
@ -391,9 +392,9 @@ addActionHandler('focusMessage', (global, actions, payload): ActionReturnType =>
chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId,
replyMessageId, isResizingContainer, shouldReplaceHistory, noForumTopicPanel,
tabId = getCurrentTabId(),
} = payload!;
} = payload;
let { messageId } = payload!;
let { messageId } = payload;
if (groupedId !== undefined) {
const ids = selectForwardedMessageIdsByGroupId(global, groupedChatId!, groupedId);
@ -759,7 +760,7 @@ addActionHandler('createServiceNotification', (global, actions, payload): Action
});
addActionHandler('openReactorListModal', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload!;
const { chatId, messageId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
reactorModal: { chatId, messageId },
@ -775,7 +776,7 @@ addActionHandler('closeReactorListModal', (global, actions, payload): ActionRetu
});
addActionHandler('openSeenByModal', (global, actions, payload): ActionReturnType => {
const { chatId, messageId, tabId = getCurrentTabId() } = payload!;
const { chatId, messageId, tabId = getCurrentTabId() } = payload;
return updateTabState(global, {
seenByModal: { chatId, messageId },
@ -790,6 +791,24 @@ addActionHandler('closeSeenByModal', (global, actions, payload): ActionReturnTyp
}, tabId);
});
addActionHandler('openMessageLanguageModal', (global, actions, payload): ActionReturnType => {
const { chatId, id, tabId = getCurrentTabId() } = payload;
const activeLanguage = selectRequestedTranslationLanguage(global, chatId, id, tabId);
return updateTabState(global, {
messageLanguageModal: { chatId, messageId: id, activeLanguage },
}, tabId);
});
addActionHandler('closeMessageLanguageModal', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload || {};
return updateTabState(global, {
messageLanguageModal: undefined,
}, tabId);
});
addActionHandler('copySelectedMessages', (global, actions, payload): ActionReturnType => {
const { tabId = getCurrentTabId() } = payload;
const tabState = selectTabState(global, tabId);

View File

@ -257,10 +257,6 @@ function unsafeMigrateCache(cached: GlobalState, initialState: GlobalState) {
cached.recentCustomEmojis = [];
}
if (cached.settings.byKey.shouldSuggestCustomEmoji === undefined) {
cached.settings.byKey.shouldSuggestCustomEmoji = true;
}
if (!cached.stickers.premiumSet) {
cached.stickers.premiumSet = {
stickers: [],

View File

@ -165,6 +165,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
wasTimeFormatSetManually: false,
isConnectionStatusMinimized: true,
shouldArchiveAndMuteNewNonContact: false,
canTranslate: false,
canTranslateChats: true,
doNotTranslate: [],
},
themes: {
light: {
@ -184,6 +187,9 @@ export const INITIAL_GLOBAL_STATE: GlobalState = {
trustedBotIds: [],
transcriptions: {},
translations: {
byChatId: {},
},
byTabId: {},
@ -267,4 +273,8 @@ export const INITIAL_TAB_STATE: TabState = {
pollModal: {
isOpen: false,
},
requestedTranslations: {
byChatId: {},
},
};

View File

@ -10,3 +10,4 @@ export * from './twoFaSettings';
export * from './passcode';
export * from './payments';
export * from './statistics';
export * from './translations';

View File

@ -1,7 +1,9 @@
import type {
GlobalState, MessageList, MessageListType, TabArgs, Thread, TabThread,
} from '../types';
import type { ApiMessage, ApiSponsoredMessage, ApiThreadInfo } from '../../api/types';
import type {
ApiMessage, ApiSponsoredMessage, ApiThreadInfo,
} from '../../api/types';
import { MAIN_THREAD_ID } from '../../api/types';
import type { FocusDirection } from '../../types';

View File

@ -0,0 +1,129 @@
import type { GlobalState, TabArgs, TranslatedMessage } from '../types';
import type { ApiFormattedText } from '../../api/types';
import { getCurrentTabId } from '../../util/establishMultitabRole';
import { omit } from '../../util/iteratees';
import { selectMessageTranslations, selectTabState } from '../selectors';
import { updateTabState } from './tabs';
export function updateMessageTranslation<T extends GlobalState>(
global: T, chatId: string, messageId: number, toLanguageCode: string, translation: Partial<TranslatedMessage>,
) {
const translatedMessages = selectMessageTranslations(global, chatId, toLanguageCode);
return {
...global,
translations: {
...global.translations,
byChatId: {
...global.translations.byChatId,
[chatId]: {
...global.translations.byChatId[chatId],
byLangCode: {
...global.translations.byChatId[chatId]?.byLangCode,
[toLanguageCode]: {
...translatedMessages,
[messageId]: {
...translatedMessages[messageId],
...translation,
},
},
},
},
},
},
};
}
export function clearMessageTranslation<T extends GlobalState>(
global: T, chatId: string, messageId: number,
) {
const chatTranslations = global.translations.byChatId[chatId];
if (!chatTranslations) return global;
const { byLangCode } = chatTranslations;
const newByLangCode = Object.keys(byLangCode).reduce((acc, langCode) => {
const newTranslatedMessages = omit(byLangCode[langCode], [messageId]);
if (Object.keys(newTranslatedMessages).length) {
acc[langCode] = newTranslatedMessages;
}
return acc;
}, {} as Record<string, Record<number, TranslatedMessage>>);
return {
...global,
translations: {
...global.translations,
byChatId: {
...global.translations.byChatId,
[chatId]: {
...chatTranslations,
byLangCode: newByLangCode,
},
},
},
};
}
export function updateMessageTranslations<T extends GlobalState>(
global: T, chatId: string, messageIds: number[], toLanguageCode: string, translations: ApiFormattedText[],
) {
messageIds.forEach((messageId, index) => {
global = updateMessageTranslation(global, chatId, messageId, toLanguageCode, {
text: translations[index],
isPending: false,
});
});
return global;
}
export function updateRequestedMessageTranslation<T extends GlobalState>(
global: T, chatId: string, messageId: number, toLanguageCode: string, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const tabState = selectTabState(global, tabId);
global = updateTabState(global, {
requestedTranslations: {
...tabState.requestedTranslations,
byChatId: {
...tabState.requestedTranslations.byChatId,
[chatId]: {
...tabState.requestedTranslations.byChatId[chatId],
manualMessages: {
...tabState.requestedTranslations.byChatId[chatId]?.manualMessages,
[messageId]: toLanguageCode,
},
},
},
},
}, tabId);
return global;
}
export function removeRequestedMessageTranslation<T extends GlobalState>(
global: T, chatId: string, messageId: number, ...[tabId = getCurrentTabId()]: TabArgs<T>
) {
const tabState = selectTabState(global, tabId);
const manualMessages = tabState.requestedTranslations.byChatId[chatId]?.manualMessages;
if (!manualMessages) return global;
const newManualMessages = omit(manualMessages, [messageId]);
global = updateTabState(global, {
requestedTranslations: {
...tabState.requestedTranslations,
byChatId: {
...tabState.requestedTranslations.byChatId,
[chatId]: {
...tabState.requestedTranslations.byChatId[chatId],
manualMessages: newManualMessages,
},
},
},
}, tabId);
return global;
}

View File

@ -1,5 +1,5 @@
import type {
GlobalState, MessageListType, TabArgs, Thread, TabThread,
GlobalState, MessageListType, TabArgs, Thread, TabThread, ChatTranslatedMessages,
} from '../types';
import type {
ApiChat,
@ -1280,6 +1280,24 @@ export function selectForwardsContainVoiceMessages<T extends GlobalState>(
});
}
export function selectChatTranslations<T extends GlobalState>(
global: T, chatId: string,
): ChatTranslatedMessages {
return global.translations.byChatId[chatId];
}
export function selectMessageTranslations<T extends GlobalState>(
global: T, chatId: string, toLanguageCode: string,
) {
return selectChatTranslations(global, chatId)?.byLangCode[toLanguageCode] || {};
}
export function selectRequestedTranslationLanguage<T extends GlobalState>(
global: T, chatId: string, messageId: number, tabId = getCurrentTabId(),
): string | undefined {
return selectTabState(global, tabId).requestedTranslations.byChatId[chatId]?.manualMessages?.[messageId];
}
export function selectForwardsCanBeSentToChat<T extends GlobalState>(
global: T,
toChatId: string,

View File

@ -7,3 +7,7 @@ export function selectNotifySettings<T extends GlobalState>(global: T) {
export function selectNotifyExceptions<T extends GlobalState>(global: T) {
return global.settings.notifyExceptions;
}
export function selectLanguageCode<T extends GlobalState>(global: T) {
return global.settings.byKey.language.replace('-raw', '');
}

View File

@ -160,6 +160,20 @@ export type ApiLimitTypeWithModal = Exclude<ApiLimitType, (
'captionLength' | 'aboutLength' | 'stickersFaved' | 'savedGifs'
)>;
export type TranslatedMessage = {
isPending?: boolean;
text?: ApiFormattedText;
};
export type ChatTranslatedMessages = {
byLangCode: Record<string, Record<number, TranslatedMessage>>;
};
export type ChatRequestedTranslations = {
toLanguage?: string;
manualMessages?: Record<number, string>;
};
export type TabState = {
id: number;
isMasterTab: boolean;
@ -520,6 +534,15 @@ export type TabState = {
topicId: number;
isLoading?: boolean;
};
requestedTranslations: {
byChatId: Record<string, ChatRequestedTranslations>;
};
messageLanguageModal?: {
chatId: string;
messageId: number;
activeLanguage?: string;
};
};
export type GlobalState = {
@ -786,6 +809,10 @@ export type GlobalState = {
isMinimized: boolean;
isHidden: boolean;
};
translations: {
byChatId: Record<string, ChatTranslatedMessages>;
};
};
export type CallSound = (
@ -1370,6 +1397,12 @@ export interface ActionPayloads {
disableContextMenuHint: WithTabId | undefined;
focusNextReply: WithTabId | undefined;
openMessageLanguageModal: {
chatId: string;
id: number;
} & WithTabId;
closeMessageLanguageModal: WithTabId | undefined;
// poll result
openPollResults: {
chatId: string;
@ -1665,6 +1698,23 @@ export interface ActionPayloads {
ids: number[];
};
requestMessageTranslation: {
chatId: string;
id: number;
toLanguageCode?: string;
} & WithTabId;
showOriginalMessage: {
chatId: string;
id: number;
} & WithTabId;
translateMessages: {
chatId: string;
messageIds: number[];
toLanguageCode?: string;
};
// Reactions
loadAvailableReactions: undefined;

View File

@ -0,0 +1,7 @@
import { detectLanguage } from '../util/languageDetection';
import useAsync from './useAsync';
export default function useTextLanguage(text?: string) {
const language = useAsync(() => (text ? detectLanguage(text) : Promise.resolve(undefined)), [text], undefined);
return language;
}

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -0,0 +1,68 @@
import { createWorkerInterface } from '../../util/createPostMessageInterface';
import fasttextInitializer from './fasttext-wasm';
import fasttextWasmPath from './fasttext-wasm.wasm';
type FastTextMethods = {
makePrediction: (type: 'predict' | 'predict-prob', text: string, k: string, threshold: string) => Promise<string>;
};
const LABEL_PREFIX = '__label__';
// Since webpack will change the name and potentially the path of the
// `.wasm` file, we have to provide a `locateFile()` hook to redirect
// to the appropriate URL.
// More details: https://kripken.github.io/emscripten-site/docs/api_reference/module.html
let fastTextInstance: FastTextMethods;
const fastTextPromise = fasttextInitializer({
locateFile: (path: string, prefix: string) => {
if (path.endsWith('.wasm')) {
return fasttextWasmPath;
}
return prefix + path;
},
}).then((fastText: FastTextMethods) => {
fastTextInstance = fastText;
// eslint-disable-next-line no-console
console.log('[FASTTEXT] Worker ready');
});
function parseLabel(label: string) {
return label.split('\n')[0].replace(LABEL_PREFIX, '').trim();
}
function parseLabelsWithProbabilities(labels: string) {
return labels.trim()
.split('\n')
.map((labelWithProb: string) => {
const [label, prob] = labelWithProb.split(' ');
return {
label: parseLabel(label),
prob: parseFloat(prob),
};
});
}
export async function detectLanguage(text: string, threshold: number) {
if (!fastTextInstance) await fastTextPromise;
const label = await fastTextInstance.makePrediction('predict', text, '1', threshold.toString());
if (!label.length) return undefined;
return parseLabel(label);
}
export async function detectLanguageProbability(text: string, labelsCount: number, threshold: number) {
if (!fastTextInstance) await fastTextPromise;
const labels = await fastTextInstance.makePrediction(
'predict-prob', text, labelsCount.toString(), threshold.toString(),
);
if (!labels.length) return undefined;
return parseLabelsWithProbabilities(labels);
}
const api = {
detectLanguage,
detectLanguageProbability,
};
createWorkerInterface(api);
export type FastTextApi = typeof api;

View File

@ -1262,6 +1262,7 @@ messages.getMessageReactionsList#461b3f48 flags:# peer:InputPeer id:int reaction
messages.setChatAvailableReactions#feb16771 peer:InputPeer available_reactions:ChatReactions = Updates;
messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions;
messages.setDefaultReaction#4f47a016 reaction:Reaction = Bool;
messages.translateText#63183030 flags:# peer:flags.0?InputPeer id:flags.0?Vector<int> text:flags.1?Vector<TextWithEntities> to_lang:string = messages.TranslatedText;
messages.getUnreadReactions#3223495b flags:# peer:InputPeer top_msg_id:flags.0?int offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages;
messages.readReactions#54aa7f8e flags:# peer:InputPeer top_msg_id:flags.0?int = messages.AffectedHistory;
messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots;

View File

@ -243,6 +243,7 @@
"messages.setChatAvailableReactions",
"messages.getAvailableReactions",
"messages.setDefaultReaction",
"messages.translateText",
"help.getAppConfig",
"stats.getBroadcastStats",
"stats.getMegagroupStats",

View File

@ -1,7 +1,9 @@
import type { RLottieApi } from './rlottie.worker';
import {
DPR, IS_SAFARI, IS_ANDROID, IS_IOS,
} from '../../util/environment';
import WorkerConnector from '../../util/WorkerConnector';
import { createConnector } from '../../util/PostMessageConnector';
import { animate } from '../../util/animation';
import cycleRestrict from '../../util/cycleRestrict';
import { fastRaf } from '../../util/schedulers';
@ -30,7 +32,7 @@ const LOW_PRIORITY_CACHE_MODULO = 0;
const instancesById = new Map<string, RLottie>();
const workers = new Array(MAX_WORKERS).fill(undefined).map(
() => new WorkerConnector(new Worker(new URL('./rlottie.worker.ts', import.meta.url))),
() => createConnector<RLottieApi>(new Worker(new URL('./rlottie.worker.ts', import.meta.url))),
);
let lastWorkerIndex = -1;
@ -108,7 +110,7 @@ class RLottie {
onLoad: NoneToVoidFunction | undefined,
private id: string,
private tgsUrl: string,
private params: Params = {},
private params: Params = { },
private customColor?: [number, number, number],
private onEnded?: (isDestroyed?: boolean) => void,
private onLoop?: () => void,
@ -360,7 +362,7 @@ class RLottie {
this.id,
this.tgsUrl,
this.imgSize,
this.params.isLowPriority,
this.params.isLowPriority || false,
this.customColor,
this.onRendererInit.bind(this),
],
@ -395,7 +397,7 @@ class RLottie {
args: [
this.id,
this.tgsUrl,
this.params.isLowPriority,
this.params.isLowPriority || false,
this.onChangeData.bind(this),
],
});

View File

@ -1,6 +1,6 @@
import { inflate } from 'pako/dist/pako_inflate';
import createWorkerInterface from '../../util/createWorkerInterface';
import type { CancellableCallback } from '../../util/WorkerConnector';
import { createWorkerInterface } from '../../util/createPostMessageInterface';
import type { CancellableCallback } from '../../util/PostMessageConnector';
import 'script-loader!./rlottie-wasm';
@ -164,9 +164,13 @@ function destroy(key: string, isRepeated = false) {
}
}
createWorkerInterface({
const api = {
init,
changeData,
renderFrames,
destroy,
});
};
createWorkerInterface(api);
export type RLottieApi = typeof api;

View File

@ -13,7 +13,7 @@ import { pause } from './util/schedulers';
declare const self: ServiceWorkerGlobalScope;
const NETWORK_FIRST_ASSETS = new Set(['/', '/rlottie-wasm.wasm', '/webp_wasm.wasm']);
const RE_NETWORK_FIRST_ASSETS = /\.(wasm|html)$/;
const RE_CACHE_FIRST_ASSETS = /[\da-f]{20}.*\.(js|css|woff2?|svg|png|jpg|jpeg|tgs|json|wasm)$/;
const ACTIVATE_TIMEOUT = 3000;
@ -64,7 +64,7 @@ self.addEventListener('fetch', (e: FetchEvent) => {
}
if (url.startsWith('http')) {
if (NETWORK_FIRST_ASSETS.has(new URL(url).pathname)) {
if (new URL(url).pathname === '/' || url.match(RE_NETWORK_FIRST_ASSETS)) {
e.respondWith(respondWithCacheNetworkFirst(e));
return true;
}

View File

@ -125,6 +125,12 @@ body.cursor-ew-resize {
}
}
.clearfix::after {
content: "";
clear: both;
display: table;
}
/*
See the article for more information on this visually-hidden pattern.
https://snook.ca/archives/html_and_css/hiding-content-for-accessibility

View File

@ -89,6 +89,9 @@ export interface ISettings extends NotifySettings, Record<string, any> {
wasTimeFormatSetManually: boolean;
isConnectionStatusMinimized: boolean;
shouldArchiveAndMuteNewNonContact?: boolean;
canTranslate: boolean;
canTranslateChats: boolean;
doNotTranslate: string[];
}
export interface ApiPrivacySettings {
@ -216,6 +219,7 @@ export enum SettingsScreens {
Stickers,
QuickReaction,
CustomEmoji,
DoNotTranslate,
}
export type StickerSetOrRecent = Pick<ApiStickerSet, (

View File

@ -0,0 +1,216 @@
import generateIdFor from './generateIdFor';
export interface CancellableCallback {
(
...args: any[]
): void;
isCanceled?: boolean;
acceptsBuffer?: boolean;
}
type InitData = {
channel?: string;
type: 'init';
messageId?: string;
name: 'init';
args: any;
};
type CallMethodData = {
channel?: string;
type: 'callMethod';
messageId?: string;
name: string;
args: any;
withCallback?: boolean;
};
export type OriginMessageData = InitData | CallMethodData | {
channel?: string;
type: 'cancelProgress';
messageId: string;
};
export interface OriginMessageEvent {
data: OriginMessageData;
}
export type ApiUpdate =
{ type: string }
& any;
export type WorkerMessageData = {
channel?: string;
type: 'update';
update: ApiUpdate;
} | {
channel?: string;
type: 'methodResponse';
messageId: string;
response?: any;
error?: { message: string };
} | {
channel?: string;
type: 'methodCallback';
messageId: string;
callbackArgs: any[];
} | {
channel?: string;
type: 'unhandledError';
error?: { message: string };
};
export interface WorkerMessageEvent {
data: WorkerMessageData;
}
interface RequestStates {
messageId: string;
resolve: Function;
reject: Function;
callback: AnyToVoidFunction;
}
type InputRequestTypes = Record<string, AnyFunction>;
type Values<T> = T[keyof T];
export type RequestTypes<T extends InputRequestTypes> = Values<{
[Name in keyof (T)]: {
name: Name & string;
args: Parameters<T[Name]>;
}
}>;
class ConnectorClass<T extends InputRequestTypes> {
private requestStates = new Map<string, RequestStates>();
private requestStatesByCallback = new Map<AnyToVoidFunction, RequestStates>();
constructor(
public target: Worker,
private onUpdate?: (update: ApiUpdate) => void,
private channel?: string,
) {
}
// eslint-disable-next-line class-methods-use-this
public destroy() {
}
init(...args: any[]) {
this.postMessage({
type: 'init',
args,
});
}
request(messageData: RequestTypes<T>) {
const { requestStates, requestStatesByCallback } = this;
const messageId = generateIdFor(requestStates);
const payload: CallMethodData = {
type: 'callMethod',
messageId,
...messageData,
};
const requestState = { messageId } as RequestStates;
// Re-wrap type because of `postMessage`
const promise: Promise<any> = new Promise((resolve, reject) => {
Object.assign(requestState, { resolve, reject });
});
if (typeof payload.args[payload.args.length - 1] === 'function') {
payload.withCallback = true;
const callback = payload.args.pop() as AnyToVoidFunction;
requestState.callback = callback;
requestStatesByCallback.set(callback, requestState);
}
requestStates.set(messageId, requestState);
promise
.catch(() => undefined)
.finally(() => {
requestStates.delete(messageId);
if (requestState.callback) {
requestStatesByCallback.delete(requestState.callback);
}
});
this.postMessage(payload);
return promise;
}
cancelCallback(progressCallback: CancellableCallback) {
progressCallback.isCanceled = true;
const { messageId } = this.requestStatesByCallback.get(progressCallback) || {};
if (!messageId) {
return;
}
this.postMessage({
type: 'cancelProgress',
messageId,
});
}
onMessage(data: WorkerMessageData) {
const { requestStates, channel } = this;
if (data.channel !== channel) {
return;
}
if (data.type === 'update' && this.onUpdate) {
this.onUpdate(data.update);
}
if (data.type === 'methodResponse') {
const requestState = requestStates.get(data.messageId);
if (requestState) {
if (data.error) {
requestState.reject(data.error);
} else {
requestState.resolve(data.response);
}
}
} else if (data.type === 'methodCallback') {
const requestState = requestStates.get(data.messageId);
requestState?.callback?.(...data.callbackArgs);
} else if (data.type === 'unhandledError') {
throw new Error(data.error?.message);
}
}
private postMessage(data: AnyLiteral) {
data.channel = this.channel;
this.target.postMessage(data);
}
}
export function createConnector<T extends InputRequestTypes>(
worker: Worker,
onUpdate?: (update: ApiUpdate) => void,
channel?: string,
) {
const connector = new ConnectorClass<T>(worker, onUpdate, channel);
function handleMessage({ data }: WorkerMessageEvent) {
connector.onMessage(data);
}
worker.addEventListener('message', handleMessage);
connector.destroy = () => {
worker.removeEventListener('message', handleMessage);
};
return connector;
}
export type Connector<T extends InputRequestTypes> = ReturnType<typeof createConnector<T>>;

View File

@ -1,140 +0,0 @@
import generateIdFor from './generateIdFor';
export interface CancellableCallback {
(
...args: any[]
): void;
isCanceled?: boolean;
acceptsBuffer?: boolean;
}
type CallMethodData = {
type: 'callMethod';
messageId?: string;
name: string;
args: any;
withCallback?: boolean;
};
type OriginMessageData = CallMethodData | {
type: 'cancelProgress';
messageId: string;
};
export interface OriginMessageEvent {
data: OriginMessageData;
}
export type WorkerMessageData = {
type: 'methodResponse';
messageId: string;
response?: any;
error?: { message: string };
} | {
type: 'methodCallback';
messageId: string;
callbackArgs: any[];
} | {
type: 'unhandledError';
error?: { message: string };
};
export interface WorkerMessageEvent {
data: WorkerMessageData;
}
interface RequestStates {
messageId: string;
resolve: Function;
reject: Function;
callback: AnyToVoidFunction;
}
// TODO Replace `any` with proper generics
export default class WorkerConnector {
private requestStates = new Map<string, RequestStates>();
private requestStatesByCallback = new Map<AnyToVoidFunction, RequestStates>();
constructor(private worker: Worker) {
this.subscribe();
}
request(messageData: { name: string; args: any }) {
const { worker, requestStates, requestStatesByCallback } = this;
const messageId = generateIdFor(requestStates);
const payload: CallMethodData = {
type: 'callMethod',
messageId,
...messageData,
};
const requestState = { messageId } as RequestStates;
// Re-wrap type because of `postMessage`
const promise: Promise<any> = new Promise((resolve, reject) => {
Object.assign(requestState, { resolve, reject });
});
if (typeof payload.args[payload.args.length - 1] === 'function') {
payload.withCallback = true;
const callback = payload.args.pop() as AnyToVoidFunction;
requestState.callback = callback;
requestStatesByCallback.set(callback, requestState);
}
requestStates.set(messageId, requestState);
promise
.catch(() => undefined)
.finally(() => {
requestStates.delete(messageId);
if (requestState.callback) {
requestStatesByCallback.delete(requestState.callback);
}
});
worker.postMessage(payload);
return promise;
}
cancelCallback(progressCallback: CancellableCallback) {
progressCallback.isCanceled = true;
const { messageId } = this.requestStatesByCallback.get(progressCallback) || {};
if (!messageId) {
return;
}
this.worker.postMessage({
type: 'cancelProgress',
messageId,
});
}
private subscribe() {
const { worker, requestStates } = this;
worker.addEventListener('message', ({ data }: WorkerMessageEvent) => {
if (data.type === 'methodResponse') {
const requestState = requestStates.get(data.messageId);
if (requestState) {
if (data.error) {
requestState.reject(data.error);
} else {
requestState.resolve(data.response);
}
}
} else if (data.type === 'methodCallback') {
const requestState = requestStates.get(data.messageId);
requestState?.callback?.(...data.callbackArgs);
} else if (data.type === 'unhandledError') {
throw new Error(data.error?.message);
}
});
}
}

View File

@ -0,0 +1,144 @@
import type {
CancellableCallback, OriginMessageEvent, OriginMessageData, WorkerMessageData, ApiUpdate,
} from './PostMessageConnector';
import { DEBUG } from '../config';
declare const self: WorkerGlobalScope;
const callbackState = new Map<string, CancellableCallback>();
type ApiConfig =
((name: string, ...args: any[]) => any | [any, ArrayBuffer[]])
| Record<string, Function>;
type SendToOrigin = (data: WorkerMessageData, transferables?: Transferable[]) => void;
export function createWorkerInterface(api: ApiConfig, channel?: string) {
function sendToOrigin(data: WorkerMessageData, transferables?: Transferable[]) {
data.channel = channel;
if (transferables) {
postMessage(data, transferables);
} else {
postMessage(data);
}
}
handleErrors(sendToOrigin);
onmessage = (message: OriginMessageEvent) => {
if (message.data?.channel === channel) {
onMessage(api, message.data, sendToOrigin);
}
};
}
async function onMessage(
api: ApiConfig,
data: OriginMessageData,
sendToOrigin: SendToOrigin,
onUpdate?: (update: ApiUpdate) => void,
) {
if (!onUpdate) {
onUpdate = (update: ApiUpdate) => {
sendToOrigin({
type: 'update',
update,
});
};
}
switch (data.type) {
case 'init': {
const { args } = data;
const promise = typeof api === 'function'
? api('init', onUpdate, ...args)
: api.init?.(onUpdate, ...args);
await promise;
break;
}
case 'callMethod': {
const {
messageId, name, args, withCallback,
} = data;
try {
if (messageId && withCallback) {
const callback = (...callbackArgs: any[]) => {
const lastArg = callbackArgs[callbackArgs.length - 1];
sendToOrigin({
type: 'methodCallback',
messageId,
callbackArgs,
}, isTransferable(lastArg) ? [lastArg] : undefined);
};
callbackState.set(messageId, callback);
args.push(callback as never);
}
const response = typeof api === 'function'
? await api(name, ...args)
: await api[name](...args);
const { arrayBuffer } = (typeof response === 'object' && 'arrayBuffer' in response && response) || {};
if (messageId) {
sendToOrigin(
{
type: 'methodResponse',
messageId,
response,
},
arrayBuffer ? [arrayBuffer] : undefined,
);
}
} catch (error: any) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.error(error);
}
if (messageId) {
sendToOrigin({
type: 'methodResponse',
messageId,
error: { message: error.message },
});
}
}
if (messageId) {
callbackState.delete(messageId);
}
break;
}
case 'cancelProgress': {
const callback = callbackState.get(data.messageId);
if (callback) {
callback.isCanceled = true;
}
break;
}
}
}
function isTransferable(obj: any) {
return obj instanceof ArrayBuffer || obj instanceof ImageBitmap;
}
function handleErrors(sendToOrigin: SendToOrigin) {
self.onerror = (e) => {
// eslint-disable-next-line no-console
console.error(e);
sendToOrigin({ type: 'unhandledError', error: { message: e.error.message || 'Uncaught exception in worker' } });
};
self.addEventListener('unhandledrejection', (e) => {
// eslint-disable-next-line no-console
console.error(e);
sendToOrigin({ type: 'unhandledError', error: { message: e.reason.message || 'Uncaught rejection in worker' } });
});
}

View File

@ -1,105 +0,0 @@
import type { CancellableCallback, OriginMessageEvent, WorkerMessageData } from './WorkerConnector';
import { DEBUG } from '../config';
declare const self: WorkerGlobalScope;
handleErrors();
const callbackState = new Map<string, CancellableCallback>();
export default function createInterface(api: Record<string, Function>) {
onmessage = async (message: OriginMessageEvent) => {
const { data } = message;
switch (data.type) {
case 'callMethod': {
const {
messageId, name, args, withCallback,
} = data;
try {
if (messageId && withCallback) {
const callback = (...callbackArgs: any[]) => {
const lastArg = callbackArgs[callbackArgs.length - 1];
sendToOrigin({
type: 'methodCallback',
messageId,
callbackArgs,
}, isTransferable(lastArg) ? [lastArg] : undefined);
};
callbackState.set(messageId, callback);
args.push(callback as never);
}
const [response, arrayBuffers] = (await api[name](...args)) || [];
if (messageId) {
sendToOrigin(
{
type: 'methodResponse',
messageId,
response,
},
arrayBuffers,
);
}
} catch (error: any) {
if (DEBUG) {
// eslint-disable-next-line no-console
console.error(error);
}
if (messageId) {
sendToOrigin({
type: 'methodResponse',
messageId,
error: { message: error.message },
});
}
}
if (messageId) {
callbackState.delete(messageId);
}
break;
}
case 'cancelProgress': {
const callback = callbackState.get(data.messageId);
if (callback) {
callback.isCanceled = true;
}
break;
}
}
};
}
function isTransferable(obj: any) {
return obj instanceof ArrayBuffer || obj instanceof ImageBitmap;
}
function handleErrors() {
self.onerror = (e) => {
// eslint-disable-next-line no-console
console.error(e);
sendToOrigin({ type: 'unhandledError', error: { message: e.error.message || 'Uncaught exception in worker' } });
};
self.addEventListener('unhandledrejection', (e) => {
// eslint-disable-next-line no-console
console.error(e);
sendToOrigin({ type: 'unhandledError', error: { message: e.reason.message || 'Uncaught rejection in worker' } });
});
}
function sendToOrigin(data: WorkerMessageData, transferables?: Transferable[]) {
if (transferables) {
postMessage(data, transferables);
} else {
postMessage(data);
}
}

View File

@ -113,6 +113,7 @@ export const IS_COMPACT_MENU = !IS_TOUCH_ENV;
export const IS_SCROLL_PATCH_NEEDED = !IS_MAC_OS && !IS_IOS && !IS_ANDROID;
export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window;
export const IS_MULTITAB_SUPPORTED = 'BroadcastChannel' in window;
export const IS_TRANSLATION_SUPPORTED = Boolean(Intl.DisplayNames);
// Smaller area reduces scroll jumps caused by `patchChromiumScroll`
export const MESSAGE_LIST_SENSITIVE_AREA = IS_SCROLL_PATCH_NEEDED ? 300 : 750;

View File

@ -117,6 +117,17 @@ export function split<T extends any>(array: T[], chunkSize: number) {
return result;
}
export function partition<T extends unknown>(
array: T[], filter: (value: T, index: number, array: T[]) => boolean,
): [T[], T[]] {
const pass: T[] = [];
const fail: T[] = [];
array.forEach((e, idx, arr) => (filter(e, idx, arr) ? pass : fail).push(e));
return [pass, fail];
}
export function cloneDeep<T>(value: T): T {
if (!isObject(value)) {
return value;

View File

@ -0,0 +1,38 @@
import type { FastTextApi } from '../lib/fasttextweb/fasttext.worker';
import type { Connector } from './PostMessageConnector';
import { createConnector } from './PostMessageConnector';
import Deferred from './Deferred';
const WORKER_INIT_DELAY = 4000;
const DEFAULT_THRESHOLD = 0.2;
const DEFAULT_LABELS_COUNT = 5;
let worker: Connector<FastTextApi> | undefined;
const initializationDeferred = new Deferred();
setTimeout(initWorker, WORKER_INIT_DELAY);
function initWorker() {
if (!worker) {
worker = createConnector<FastTextApi>(
new Worker(new URL('../lib/fasttextweb/fasttext.worker.ts', import.meta.url)),
);
initializationDeferred.resolve();
}
}
export async function detectLanguage(text: string, threshold = DEFAULT_THRESHOLD) {
if (!worker) await initializationDeferred.promise;
const result = await worker!.request({ name: 'detectLanguage', args: [text, threshold] });
return result;
}
export async function detectLanguageProbability(
text: string, labelsCount = DEFAULT_LABELS_COUNT, threshold = DEFAULT_THRESHOLD,
) {
if (!worker) await initializationDeferred.promise;
const result = await worker!.request({ name: 'detectLanguageProbability', args: [text, labelsCount, threshold] });
return result;
}