diff --git a/package-lock.json b/package-lock.json index 277ff0e39..0eea7d9fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@tauri-apps/cli": "^2.9.4", "@testing-library/jest-dom": "^6.9.1", "@twbs/fantasticon": "^3.1.0", + "@types/dom-chromium-ai": "^0.0.11", "@types/dom-view-transitions": "^1.0.6", "@types/hast": "^3.0.4", "@types/jest": "^30.0.0", @@ -5616,6 +5617,13 @@ "@types/node": "*" } }, + "node_modules/@types/dom-chromium-ai": { + "version": "0.0.11", + "resolved": "https://registry.npmjs.org/@types/dom-chromium-ai/-/dom-chromium-ai-0.0.11.tgz", + "integrity": "sha512-Li04Mac9ic1vbX/te9re8v1010fh5YB/30dMcJLpIuIyDoT7xE/dIdg9r9UrFZLs5Ztmonb3nP7+LhPpFuHBGw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dom-view-transitions": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/dom-view-transitions/-/dom-view-transitions-1.0.6.tgz", diff --git a/package.json b/package.json index a566634cd..e046daa2f 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@tauri-apps/cli": "^2.9.4", "@testing-library/jest-dom": "^6.9.1", "@twbs/fantasticon": "^3.1.0", + "@types/dom-chromium-ai": "^0.0.11", "@types/dom-view-transitions": "^1.0.6", "@types/hast": "^3.0.4", "@types/jest": "^30.0.0", diff --git a/src/components/left/settings/SettingsDoNotTranslate.tsx b/src/components/left/settings/SettingsDoNotTranslate.tsx index 99dd3a342..fba7ca710 100644 --- a/src/components/left/settings/SettingsDoNotTranslate.tsx +++ b/src/components/left/settings/SettingsDoNotTranslate.tsx @@ -1,4 +1,3 @@ -import type { FC } from '../../../lib/teact/teact'; import { memo, useMemo, useState, } from '../../../lib/teact/teact'; @@ -8,43 +7,15 @@ import type { AccountSettings } from '../../../types'; import { SUPPORTED_TRANSLATION_LANGUAGES } from '../../../config'; import buildClassName from '../../../util/buildClassName'; -import { partition } from '../../../util/iteratees'; -import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useHistoryBack from '../../../hooks/useHistoryBack'; +import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; -import useOldLang from '../../../hooks/useOldLang'; import ItemPicker, { type ItemPickerOption } from '../../common/pickers/ItemPicker'; 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; @@ -52,36 +23,35 @@ type OwnProps = { type StateProps = Pick; -const SettingsDoNotTranslate: FC = ({ +const SettingsDoNotTranslate = ({ isActive, doNotTranslate, onReset, -}) => { +}: OwnProps & StateProps) => { const { setSettingOption } = getActions(); - const lang = useOldLang(); - const language = lang.code || 'en'; - const [displayedOptions, setDisplayedOptions] = useState([]); + const lang = useLang(); + const language = lang.code; const [searchQuery, setSearchQuery] = useState(''); const displayedOptionList: ItemPickerOption[] = useMemo(() => { - const options = SUPPORTED_LANGUAGES.map((langCode: string) => { - const translatedNames = new Intl.DisplayNames([language], { type: 'language' }); - const translatedName = translatedNames.of(langCode)!; + const translatedNames = new Intl.DisplayNames([language], { type: 'language' }); + const options = SUPPORTED_TRANSLATION_LANGUAGES.map((langCode: string) => { + const translatedName = translatedNames.of(langCode); - const originalNames = new Intl.DisplayNames([langCode], { type: 'language' }); - const originalName = originalNames.of(langCode)!; + const originalName = new Intl.DisplayNames([langCode], { type: 'language' }) + .of(langCode); + + if (!translatedName || !originalName) { + return undefined; + } return { - langCode, - translatedName, - originalName, + value: langCode, + label: translatedName, + subLabel: originalName, }; - }).filter(Boolean).map(({ langCode, translatedName, originalName }) => ({ - label: translatedName, - subLabel: originalName, - value: langCode, - })); + }).filter(Boolean); if (!searchQuery.trim()) { const currentLanguageOption = options.find((option) => option.value === language); @@ -89,17 +59,14 @@ const SettingsDoNotTranslate: FC = ({ return currentLanguageOption ? [currentLanguageOption, ...otherOptionList] : options; } - return options?.filter((option) => option.label.toLowerCase().includes(searchQuery.toLowerCase())); + return options?.filter((option) => ( + option.label.toLowerCase().includes(searchQuery.toLowerCase()) + || option.subLabel?.toLowerCase().includes(searchQuery.toLowerCase()) + || option.value.toLowerCase().includes(searchQuery.toLowerCase()) + )); }, [language, searchQuery]); - useEffectWithPrevDeps(([prevIsActive, prevLanguage]) => { - if (prevIsActive === isActive && prevLanguage?.find((option) => option === language)) return; - const [selected] = partition(displayedOptionList, (option) => doNotTranslate.includes(option.value)); - setDisplayedOptions([...selected.map((option) => option.value)]); - }, [isActive, doNotTranslate, displayedOptions.length, language, displayedOptionList]); - const handleChange = useLastCallback((newSelectedIds: string[]) => { - setDisplayedOptions(newSelectedIds); setSettingOption({ doNotTranslate: newSelectedIds, }); @@ -116,7 +83,7 @@ const SettingsDoNotTranslate: FC = ({ ) { - const [language, setLanguage] = useState(); + const [language, setLanguage] = useState(); + const lastTextRef = useRef(); useEffect(() => { - if (isDisabled || (getIsReady && !getIsReady())) return; + if (isDisabled || (getIsReady && !getIsReady()) || lastTextRef.current === text) return; - if (text) { - detectLanguage(text).then(setLanguage); - } else { + let isCancelled = false; + + if (!text) { setLanguage(undefined); + lastTextRef.current = undefined; + return; } + + detectLanguage(text).then((lang) => { + if (isCancelled) { + return; + } + + setLanguage(lang); + }).finally(() => { + if (isCancelled) { + return; + } + + lastTextRef.current = text; + }); + + return () => { + isCancelled = true; + }; }, [isDisabled, text, getIsReady]); return language; diff --git a/src/lib/fasttextweb/fasttext.worker.ts b/src/lib/fasttextweb/fasttext.worker.ts index 8152f25cc..884d146ae 100644 --- a/src/lib/fasttextweb/fasttext.worker.ts +++ b/src/lib/fasttextweb/fasttext.worker.ts @@ -39,8 +39,8 @@ function parseLabelsWithProbabilities(labels: string) { .map((labelWithProb: string) => { const [label, prob] = labelWithProb.split(' '); return { - label: parseLabel(label), - prob: parseFloat(prob), + detectedLanguage: parseLabel(label), + confidence: parseFloat(prob), }; }); } diff --git a/src/util/browser/windowEnvironment.ts b/src/util/browser/windowEnvironment.ts index 80711abf3..edd45cbf5 100644 --- a/src/util/browser/windowEnvironment.ts +++ b/src/util/browser/windowEnvironment.ts @@ -111,6 +111,7 @@ export const IS_BACKDROP_BLUR_SUPPORTED = CSS.supports('backdrop-filter: blur()' export const IS_INSTALL_PROMPT_SUPPORTED = 'onbeforeinstallprompt' in window; export const IS_OPEN_IN_NEW_TAB_SUPPORTED = !(IS_PWA && IS_MOBILE); export const IS_TRANSLATION_SUPPORTED = !IS_TEST; +export const IS_TRANSLATION_DETECTOR_SUPPORTED = 'LanguageDetector' in window; export const IS_VIEW_TRANSITION_SUPPORTED = CSS.supports('view-transition-class: test') && !IS_FIREFOX; // Fix flashing elements before removing diff --git a/src/util/languageDetection.ts b/src/util/languageDetection.ts index 802700039..d02bc7b57 100644 --- a/src/util/languageDetection.ts +++ b/src/util/languageDetection.ts @@ -1,24 +1,40 @@ import type { FastTextApi } from '../lib/fasttextweb/fasttext.worker'; import type { Connector } from './PostMessageConnector'; -import { IS_TRANSLATION_SUPPORTED } from './browser/windowEnvironment'; +import { DEBUG } from '../config'; +import { IS_TRANSLATION_DETECTOR_SUPPORTED, IS_TRANSLATION_SUPPORTED } from './browser/windowEnvironment'; import Deferred from './Deferred'; import { createConnector } from './PostMessageConnector'; -const WORKER_INIT_DELAY = 4000; +const DETECTOR_INIT_DELAY = 4000; const DEFAULT_THRESHOLD = 0.2; const DEFAULT_LABELS_COUNT = 5; +const UNDEFINED_LANGUAGE = 'und'; + let worker: Connector | undefined; +let languageDetector: LanguageDetector | undefined; const initializationDeferred = new Deferred(); if (IS_TRANSLATION_SUPPORTED) { - setTimeout(initWorker, WORKER_INIT_DELAY); + setTimeout(initLanguageDetection, DETECTOR_INIT_DELAY); } -function initWorker() { +async function initLanguageDetection() { + if (isInitialized()) return; + if (IS_TRANSLATION_DETECTOR_SUPPORTED) { + try { + languageDetector = await LanguageDetector.create(); + initializationDeferred.resolve(); + return; + } catch (error) { + // eslint-disable-next-line no-console + if (DEBUG) console.error('Failed to initialize language detector: ', error); + } + } + if (!worker) { worker = createConnector( new Worker(new URL('../lib/fasttextweb/fasttext.worker.ts', import.meta.url)), @@ -27,16 +43,53 @@ function initWorker() { } } -export async function detectLanguage(text: string, threshold = DEFAULT_THRESHOLD) { - if (!worker) await initializationDeferred.promise; +function isInitialized() { + return Boolean(languageDetector || worker); +} + +export async function detectLanguage(text: string, threshold = DEFAULT_THRESHOLD): Promise { + if (!isInitialized()) await initializationDeferred.promise; + + if (languageDetector) { + try { + const results = await languageDetector.detect(text); + const first = results[0]; + if ( + !first + || first.detectedLanguage === UNDEFINED_LANGUAGE + || !first.confidence + || first.confidence < threshold + ) return undefined; + + return first.detectedLanguage; + } catch (error) { + // eslint-disable-next-line no-console + if (DEBUG) console.error('Failed to detect language: ', error); + return undefined; + } + } + 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; +): Promise { + if (!isInitialized()) await initializationDeferred.promise; + if (languageDetector) { + try { + const results = await languageDetector.detect(text); + return results.filter((result) => result.detectedLanguage !== UNDEFINED_LANGUAGE + && (result.confidence && result.confidence >= threshold)) + .slice(0, labelsCount); + } catch (error) { + // eslint-disable-next-line no-console + if (DEBUG) console.error('Failed to detect language probability: ', error); + return undefined; + } + } + const result = await worker!.request({ name: 'detectLanguageProbability', args: [text, labelsCount, threshold] }); return result; }