Message Context Menu: Introduce Translate feature
This commit is contained in:
parent
da70c3893d
commit
515394d35e
@ -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
|
||||
|
||||
1
src/@types/global.d.ts
vendored
1
src/@types/global.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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) || [],
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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];
|
||||
}, []);
|
||||
}
|
||||
|
||||
@ -298,6 +298,10 @@ const LeftColumn: FC<StateProps> = ({
|
||||
case SettingsScreens.CustomEmoji:
|
||||
setSettingsScreen(SettingsScreens.Stickers);
|
||||
return;
|
||||
|
||||
case SettingsScreens.DoNotTranslate:
|
||||
setSettingsScreen(SettingsScreens.Language);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@ -306,6 +306,10 @@
|
||||
.Radio:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.Checkbox {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.Radio + .Radio,
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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();
|
||||
}
|
||||
183
src/components/left/settings/SettingsDoNotTranslate.tsx
Normal file
183
src/components/left/settings/SettingsDoNotTranslate.tsx
Normal 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));
|
||||
@ -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:
|
||||
|
||||
@ -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));
|
||||
|
||||
17
src/components/middle/MessageLanguageModal.async.tsx
Normal file
17
src/components/middle/MessageLanguageModal.async.tsx
Normal 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);
|
||||
30
src/components/middle/MessageLanguageModal.module.scss
Normal file
30
src/components/middle/MessageLanguageModal.module.scss
Normal 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();
|
||||
}
|
||||
147
src/components/middle/MessageLanguageModal.tsx
Normal file
147
src/components/middle/MessageLanguageModal.tsx
Normal 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));
|
||||
@ -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,
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
27
src/components/middle/message/hooks/useMessageTranslation.ts
Normal file
27
src/components/middle/message/hooks/useMessageTranslation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -28,6 +28,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.slim {
|
||||
.modal-dialog {
|
||||
max-width: 25rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: min(92vh, 32rem);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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_-]+)';
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
@ -10,3 +10,4 @@ export * from './twoFaSettings';
|
||||
export * from './passcode';
|
||||
export * from './payments';
|
||||
export * from './statistics';
|
||||
export * from './translations';
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
129
src/global/reducers/translations.ts
Normal file
129
src/global/reducers/translations.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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', '');
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
7
src/hooks/useTextLanguage.ts
Normal file
7
src/hooks/useTextLanguage.ts
Normal 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;
|
||||
}
|
||||
20
src/lib/fasttextweb/fasttext-wasm.js
Normal file
20
src/lib/fasttextweb/fasttext-wasm.js
Normal file
File diff suppressed because one or more lines are too long
BIN
src/lib/fasttextweb/fasttext-wasm.wasm
Normal file
BIN
src/lib/fasttextweb/fasttext-wasm.wasm
Normal file
Binary file not shown.
68
src/lib/fasttextweb/fasttext.worker.ts
Normal file
68
src/lib/fasttextweb/fasttext.worker.ts
Normal 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;
|
||||
@ -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;
|
||||
|
||||
@ -243,6 +243,7 @@
|
||||
"messages.setChatAvailableReactions",
|
||||
"messages.getAvailableReactions",
|
||||
"messages.setDefaultReaction",
|
||||
"messages.translateText",
|
||||
"help.getAppConfig",
|
||||
"stats.getBroadcastStats",
|
||||
"stats.getMegagroupStats",
|
||||
|
||||
@ -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),
|
||||
],
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, (
|
||||
|
||||
216
src/util/PostMessageConnector.ts
Normal file
216
src/util/PostMessageConnector.ts
Normal 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>>;
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
144
src/util/createPostMessageInterface.ts
Normal file
144
src/util/createPostMessageInterface.ts
Normal 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' } });
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
38
src/util/languageDetection.ts
Normal file
38
src/util/languageDetection.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user