diff --git a/scripts/fetch-lingva-languages.js b/scripts/fetch-lingva-languages.js new file mode 100644 index 00000000..f270cabe --- /dev/null +++ b/scripts/fetch-lingva-languages.js @@ -0,0 +1,18 @@ +// Fetch https://lingva.ml/api/v1/languages/{source|target} +import fs from 'fs'; + +fetch('https://lingva.ml/api/v1/languages/source') + .then((response) => response.json()) + .then((json) => { + const file = './src/data/lingva-source-languages.json'; + console.log(`Writing ${file}...`); + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); + }); + +fetch('https://lingva.ml/api/v1/languages/target') + .then((response) => response.json()) + .then((json) => { + const file = './src/data/lingva-target-languages.json'; + console.log(`Writing ${file}...`); + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); + }); diff --git a/src/app.css b/src/app.css index 62cc23de..beae550b 100644 --- a/src/app.css +++ b/src/app.css @@ -1007,6 +1007,12 @@ body:has(.status-deck) .media-post-link { .sheet header :is(h1, h2, h3) { margin: 0; } +.sheet header.header-grid { + display: grid; + grid-template-columns: 1fr auto; + grid-gap: 8px; + align-items: center; +} .sheet main { overflow: auto; overflow-x: hidden; diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 7a5edd00..805823d9 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -63,6 +63,7 @@ const ICONS = { share: 'mingcute:share-2-line', sparkles: 'mingcute:sparkles-line', exit: 'mingcute:exit-line', + translate: 'mingcute:translate-line', }; const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js'); diff --git a/src/components/loader.css b/src/components/loader.css index dd327cb4..c93214bb 100644 --- a/src/components/loader.css +++ b/src/components/loader.css @@ -6,6 +6,7 @@ animation: appear 0.3s ease-in-out 1s both; vertical-align: middle; margin: 8px; + vertical-align: baseline !important; } .loader-container.abrupt { animation: none; diff --git a/src/components/media-modal.jsx b/src/components/media-modal.jsx index d79d7388..6c85b3dd 100644 --- a/src/components/media-modal.jsx +++ b/src/components/media-modal.jsx @@ -1,3 +1,4 @@ +import { Menu, MenuItem } from '@szhsin/react-menu'; import { getBlurHashAverageColor } from 'fast-blurhash'; import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -6,6 +7,7 @@ import Icon from './icon'; import Link from './link'; import Media from './media'; import Modal from './modal'; +import TranslationBlock from './translation-block'; function MediaModal({ mediaAttachments, @@ -234,24 +236,54 @@ function MediaModal({ } }} > -
-
-

Media description

-
-
-

- {showMediaAlt} -

-
-
+ )} ); } +function MediaAltModal({ alt }) { + const [forceTranslate, setForceTranslate] = useState(false); + return ( +
+
+

Media description

+
+ + + + } + > + { + setForceTranslate(true); + }} + > + + Translate + + +
+
+
+

+ {alt} +

+ {forceTranslate && ( + + )} +
+
+ ); +} + export default MediaModal; diff --git a/src/components/status.jsx b/src/components/status.jsx index fca28e24..04645030 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -20,6 +20,7 @@ import Modal from '../components/modal'; import NameText from '../components/name-text'; import { api } from '../utils/api'; import enhanceContent from '../utils/enhance-content'; +import getTranslateTargetLanguage from '../utils/get-translate-target-language'; import handleContentLinks from '../utils/handle-content-links'; import htmlContentLength from '../utils/html-content-length'; import niceDateTime from '../utils/nice-date-time'; @@ -35,6 +36,7 @@ import Link from './link'; import Media from './media'; import MenuLink from './MenuLink'; import RelativeTime from './relative-time'; +import TranslationBlock from './translation-block'; const throttle = pThrottle({ limit: 1, @@ -66,6 +68,7 @@ function Status({ skeleton, readOnly, contentTextWeight, + enableTranslate, }) { if (skeleton) { return ( @@ -194,6 +197,10 @@ function Status({ ); } + const [forceTranslate, setForceTranslate] = useState(false); + const targetLanguage = getTranslateTargetLanguage(true); + if (!snapStates.settings.contentTranslation) enableTranslate = false; + const [showEdited, setShowEdited] = useState(false); const spoilerContentRef = useRef(null); @@ -450,6 +457,17 @@ function Status({ Copy link to post + {enableTranslate && ( + { + setForceTranslate(true); + }} + > + + Translate + + )} {navigator?.share && navigator?.canShare?.({ url, @@ -770,6 +788,25 @@ function Status({ }} /> )} + {((enableTranslate && + !!content.trim() && + language && + language !== targetLanguage) || + forceTranslate) && ( + `- ${option.title}`) + .join('\n')}` + : '') + } + /> + )} {!spoilerText && sensitive && !!mediaAttachments.length && ( + +
+
+ {' '} + → {targetLangText} +
+ {uiState === 'error' ? ( +

Failed to translate

+ ) : ( + !!translatedContent && ( + <> + {!!pronunciationContent && ( + + {pronunciationContent} + + )} + + {translatedContent} + + + ) + )} +
+ + + ); +} + +export default TranslationBlock; diff --git a/src/data/lingva-source-languages.json b/src/data/lingva-source-languages.json new file mode 100644 index 00000000..bcde98d2 --- /dev/null +++ b/src/data/lingva-source-languages.json @@ -0,0 +1,534 @@ +[ + { + "code": "auto", + "name": "Detect" + }, + { + "code": "af", + "name": "Afrikaans" + }, + { + "code": "sq", + "name": "Albanian" + }, + { + "code": "am", + "name": "Amharic" + }, + { + "code": "ar", + "name": "Arabic" + }, + { + "code": "hy", + "name": "Armenian" + }, + { + "code": "as", + "name": "Assamese" + }, + { + "code": "ay", + "name": "Aymara" + }, + { + "code": "az", + "name": "Azerbaijani" + }, + { + "code": "bm", + "name": "Bambara" + }, + { + "code": "eu", + "name": "Basque" + }, + { + "code": "be", + "name": "Belarusian" + }, + { + "code": "bn", + "name": "Bengali" + }, + { + "code": "bho", + "name": "Bhojpuri" + }, + { + "code": "bs", + "name": "Bosnian" + }, + { + "code": "bg", + "name": "Bulgarian" + }, + { + "code": "ca", + "name": "Catalan" + }, + { + "code": "ceb", + "name": "Cebuano" + }, + { + "code": "ny", + "name": "Chichewa" + }, + { + "code": "zh", + "name": "Chinese" + }, + { + "code": "co", + "name": "Corsican" + }, + { + "code": "hr", + "name": "Croatian" + }, + { + "code": "cs", + "name": "Czech" + }, + { + "code": "da", + "name": "Danish" + }, + { + "code": "dv", + "name": "Dhivehi" + }, + { + "code": "doi", + "name": "Dogri" + }, + { + "code": "nl", + "name": "Dutch" + }, + { + "code": "en", + "name": "English" + }, + { + "code": "eo", + "name": "Esperanto" + }, + { + "code": "et", + "name": "Estonian" + }, + { + "code": "ee", + "name": "Ewe" + }, + { + "code": "tl", + "name": "Filipino" + }, + { + "code": "fi", + "name": "Finnish" + }, + { + "code": "fr", + "name": "French" + }, + { + "code": "fy", + "name": "Frisian" + }, + { + "code": "gl", + "name": "Galician" + }, + { + "code": "ka", + "name": "Georgian" + }, + { + "code": "de", + "name": "German" + }, + { + "code": "el", + "name": "Greek" + }, + { + "code": "gn", + "name": "Guarani" + }, + { + "code": "gu", + "name": "Gujarati" + }, + { + "code": "ht", + "name": "Haitian Creole" + }, + { + "code": "ha", + "name": "Hausa" + }, + { + "code": "haw", + "name": "Hawaiian" + }, + { + "code": "iw", + "name": "Hebrew" + }, + { + "code": "hi", + "name": "Hindi" + }, + { + "code": "hmn", + "name": "Hmong" + }, + { + "code": "hu", + "name": "Hungarian" + }, + { + "code": "is", + "name": "Icelandic" + }, + { + "code": "ig", + "name": "Igbo" + }, + { + "code": "ilo", + "name": "Ilocano" + }, + { + "code": "id", + "name": "Indonesian" + }, + { + "code": "ga", + "name": "Irish" + }, + { + "code": "it", + "name": "Italian" + }, + { + "code": "ja", + "name": "Japanese" + }, + { + "code": "jw", + "name": "Javanese" + }, + { + "code": "kn", + "name": "Kannada" + }, + { + "code": "kk", + "name": "Kazakh" + }, + { + "code": "km", + "name": "Khmer" + }, + { + "code": "rw", + "name": "Kinyarwanda" + }, + { + "code": "gom", + "name": "Konkani" + }, + { + "code": "ko", + "name": "Korean" + }, + { + "code": "kri", + "name": "Krio" + }, + { + "code": "ku", + "name": "Kurdish (Kurmanji)" + }, + { + "code": "ckb", + "name": "Kurdish (Sorani)" + }, + { + "code": "ky", + "name": "Kyrgyz" + }, + { + "code": "lo", + "name": "Lao" + }, + { + "code": "la", + "name": "Latin" + }, + { + "code": "lv", + "name": "Latvian" + }, + { + "code": "ln", + "name": "Lingala" + }, + { + "code": "lt", + "name": "Lithuanian" + }, + { + "code": "lg", + "name": "Luganda" + }, + { + "code": "lb", + "name": "Luxembourgish" + }, + { + "code": "mk", + "name": "Macedonian" + }, + { + "code": "mai", + "name": "Maithili" + }, + { + "code": "mg", + "name": "Malagasy" + }, + { + "code": "ms", + "name": "Malay" + }, + { + "code": "ml", + "name": "Malayalam" + }, + { + "code": "mt", + "name": "Maltese" + }, + { + "code": "mi", + "name": "Maori" + }, + { + "code": "mr", + "name": "Marathi" + }, + { + "code": "mni-Mtei", + "name": "Meiteilon (Manipuri)" + }, + { + "code": "lus", + "name": "Mizo" + }, + { + "code": "mn", + "name": "Mongolian" + }, + { + "code": "my", + "name": "Myanmar (Burmese)" + }, + { + "code": "ne", + "name": "Nepali" + }, + { + "code": "no", + "name": "Norwegian" + }, + { + "code": "or", + "name": "Odia (Oriya)" + }, + { + "code": "om", + "name": "Oromo" + }, + { + "code": "ps", + "name": "Pashto" + }, + { + "code": "fa", + "name": "Persian" + }, + { + "code": "pl", + "name": "Polish" + }, + { + "code": "pt", + "name": "Portuguese" + }, + { + "code": "pa", + "name": "Punjabi" + }, + { + "code": "qu", + "name": "Quechua" + }, + { + "code": "ro", + "name": "Romanian" + }, + { + "code": "ru", + "name": "Russian" + }, + { + "code": "sm", + "name": "Samoan" + }, + { + "code": "sa", + "name": "Sanskrit" + }, + { + "code": "gd", + "name": "Scots Gaelic" + }, + { + "code": "nso", + "name": "Sepedi" + }, + { + "code": "sr", + "name": "Serbian" + }, + { + "code": "st", + "name": "Sesotho" + }, + { + "code": "sn", + "name": "Shona" + }, + { + "code": "sd", + "name": "Sindhi" + }, + { + "code": "si", + "name": "Sinhala" + }, + { + "code": "sk", + "name": "Slovak" + }, + { + "code": "sl", + "name": "Slovenian" + }, + { + "code": "so", + "name": "Somali" + }, + { + "code": "es", + "name": "Spanish" + }, + { + "code": "su", + "name": "Sundanese" + }, + { + "code": "sw", + "name": "Swahili" + }, + { + "code": "sv", + "name": "Swedish" + }, + { + "code": "tg", + "name": "Tajik" + }, + { + "code": "ta", + "name": "Tamil" + }, + { + "code": "tt", + "name": "Tatar" + }, + { + "code": "te", + "name": "Telugu" + }, + { + "code": "th", + "name": "Thai" + }, + { + "code": "ti", + "name": "Tigrinya" + }, + { + "code": "ts", + "name": "Tsonga" + }, + { + "code": "tr", + "name": "Turkish" + }, + { + "code": "tk", + "name": "Turkmen" + }, + { + "code": "ak", + "name": "Twi" + }, + { + "code": "uk", + "name": "Ukrainian" + }, + { + "code": "ur", + "name": "Urdu" + }, + { + "code": "ug", + "name": "Uyghur" + }, + { + "code": "uz", + "name": "Uzbek" + }, + { + "code": "vi", + "name": "Vietnamese" + }, + { + "code": "cy", + "name": "Welsh" + }, + { + "code": "xh", + "name": "Xhosa" + }, + { + "code": "yi", + "name": "Yiddish" + }, + { + "code": "yo", + "name": "Yoruba" + }, + { + "code": "zu", + "name": "Zulu" + } +] \ No newline at end of file diff --git a/src/data/lingva-target-languages.json b/src/data/lingva-target-languages.json new file mode 100644 index 00000000..b8c760de --- /dev/null +++ b/src/data/lingva-target-languages.json @@ -0,0 +1,534 @@ +[ + { + "code": "af", + "name": "Afrikaans" + }, + { + "code": "sq", + "name": "Albanian" + }, + { + "code": "am", + "name": "Amharic" + }, + { + "code": "ar", + "name": "Arabic" + }, + { + "code": "hy", + "name": "Armenian" + }, + { + "code": "as", + "name": "Assamese" + }, + { + "code": "ay", + "name": "Aymara" + }, + { + "code": "az", + "name": "Azerbaijani" + }, + { + "code": "bm", + "name": "Bambara" + }, + { + "code": "eu", + "name": "Basque" + }, + { + "code": "be", + "name": "Belarusian" + }, + { + "code": "bn", + "name": "Bengali" + }, + { + "code": "bho", + "name": "Bhojpuri" + }, + { + "code": "bs", + "name": "Bosnian" + }, + { + "code": "bg", + "name": "Bulgarian" + }, + { + "code": "ca", + "name": "Catalan" + }, + { + "code": "ceb", + "name": "Cebuano" + }, + { + "code": "ny", + "name": "Chichewa" + }, + { + "code": "zh", + "name": "Chinese" + }, + { + "code": "zh_HANT", + "name": "Chinese (Traditional)" + }, + { + "code": "co", + "name": "Corsican" + }, + { + "code": "hr", + "name": "Croatian" + }, + { + "code": "cs", + "name": "Czech" + }, + { + "code": "da", + "name": "Danish" + }, + { + "code": "dv", + "name": "Dhivehi" + }, + { + "code": "doi", + "name": "Dogri" + }, + { + "code": "nl", + "name": "Dutch" + }, + { + "code": "en", + "name": "English" + }, + { + "code": "eo", + "name": "Esperanto" + }, + { + "code": "et", + "name": "Estonian" + }, + { + "code": "ee", + "name": "Ewe" + }, + { + "code": "tl", + "name": "Filipino" + }, + { + "code": "fi", + "name": "Finnish" + }, + { + "code": "fr", + "name": "French" + }, + { + "code": "fy", + "name": "Frisian" + }, + { + "code": "gl", + "name": "Galician" + }, + { + "code": "ka", + "name": "Georgian" + }, + { + "code": "de", + "name": "German" + }, + { + "code": "el", + "name": "Greek" + }, + { + "code": "gn", + "name": "Guarani" + }, + { + "code": "gu", + "name": "Gujarati" + }, + { + "code": "ht", + "name": "Haitian Creole" + }, + { + "code": "ha", + "name": "Hausa" + }, + { + "code": "haw", + "name": "Hawaiian" + }, + { + "code": "iw", + "name": "Hebrew" + }, + { + "code": "hi", + "name": "Hindi" + }, + { + "code": "hmn", + "name": "Hmong" + }, + { + "code": "hu", + "name": "Hungarian" + }, + { + "code": "is", + "name": "Icelandic" + }, + { + "code": "ig", + "name": "Igbo" + }, + { + "code": "ilo", + "name": "Ilocano" + }, + { + "code": "id", + "name": "Indonesian" + }, + { + "code": "ga", + "name": "Irish" + }, + { + "code": "it", + "name": "Italian" + }, + { + "code": "ja", + "name": "Japanese" + }, + { + "code": "jw", + "name": "Javanese" + }, + { + "code": "kn", + "name": "Kannada" + }, + { + "code": "kk", + "name": "Kazakh" + }, + { + "code": "km", + "name": "Khmer" + }, + { + "code": "rw", + "name": "Kinyarwanda" + }, + { + "code": "gom", + "name": "Konkani" + }, + { + "code": "ko", + "name": "Korean" + }, + { + "code": "kri", + "name": "Krio" + }, + { + "code": "ku", + "name": "Kurdish (Kurmanji)" + }, + { + "code": "ckb", + "name": "Kurdish (Sorani)" + }, + { + "code": "ky", + "name": "Kyrgyz" + }, + { + "code": "lo", + "name": "Lao" + }, + { + "code": "la", + "name": "Latin" + }, + { + "code": "lv", + "name": "Latvian" + }, + { + "code": "ln", + "name": "Lingala" + }, + { + "code": "lt", + "name": "Lithuanian" + }, + { + "code": "lg", + "name": "Luganda" + }, + { + "code": "lb", + "name": "Luxembourgish" + }, + { + "code": "mk", + "name": "Macedonian" + }, + { + "code": "mai", + "name": "Maithili" + }, + { + "code": "mg", + "name": "Malagasy" + }, + { + "code": "ms", + "name": "Malay" + }, + { + "code": "ml", + "name": "Malayalam" + }, + { + "code": "mt", + "name": "Maltese" + }, + { + "code": "mi", + "name": "Maori" + }, + { + "code": "mr", + "name": "Marathi" + }, + { + "code": "mni-Mtei", + "name": "Meiteilon (Manipuri)" + }, + { + "code": "lus", + "name": "Mizo" + }, + { + "code": "mn", + "name": "Mongolian" + }, + { + "code": "my", + "name": "Myanmar (Burmese)" + }, + { + "code": "ne", + "name": "Nepali" + }, + { + "code": "no", + "name": "Norwegian" + }, + { + "code": "or", + "name": "Odia (Oriya)" + }, + { + "code": "om", + "name": "Oromo" + }, + { + "code": "ps", + "name": "Pashto" + }, + { + "code": "fa", + "name": "Persian" + }, + { + "code": "pl", + "name": "Polish" + }, + { + "code": "pt", + "name": "Portuguese" + }, + { + "code": "pa", + "name": "Punjabi" + }, + { + "code": "qu", + "name": "Quechua" + }, + { + "code": "ro", + "name": "Romanian" + }, + { + "code": "ru", + "name": "Russian" + }, + { + "code": "sm", + "name": "Samoan" + }, + { + "code": "sa", + "name": "Sanskrit" + }, + { + "code": "gd", + "name": "Scots Gaelic" + }, + { + "code": "nso", + "name": "Sepedi" + }, + { + "code": "sr", + "name": "Serbian" + }, + { + "code": "st", + "name": "Sesotho" + }, + { + "code": "sn", + "name": "Shona" + }, + { + "code": "sd", + "name": "Sindhi" + }, + { + "code": "si", + "name": "Sinhala" + }, + { + "code": "sk", + "name": "Slovak" + }, + { + "code": "sl", + "name": "Slovenian" + }, + { + "code": "so", + "name": "Somali" + }, + { + "code": "es", + "name": "Spanish" + }, + { + "code": "su", + "name": "Sundanese" + }, + { + "code": "sw", + "name": "Swahili" + }, + { + "code": "sv", + "name": "Swedish" + }, + { + "code": "tg", + "name": "Tajik" + }, + { + "code": "ta", + "name": "Tamil" + }, + { + "code": "tt", + "name": "Tatar" + }, + { + "code": "te", + "name": "Telugu" + }, + { + "code": "th", + "name": "Thai" + }, + { + "code": "ti", + "name": "Tigrinya" + }, + { + "code": "ts", + "name": "Tsonga" + }, + { + "code": "tr", + "name": "Turkish" + }, + { + "code": "tk", + "name": "Turkmen" + }, + { + "code": "ak", + "name": "Twi" + }, + { + "code": "uk", + "name": "Ukrainian" + }, + { + "code": "ur", + "name": "Urdu" + }, + { + "code": "ug", + "name": "Uyghur" + }, + { + "code": "uz", + "name": "Uzbek" + }, + { + "code": "vi", + "name": "Vietnamese" + }, + { + "code": "cy", + "name": "Welsh" + }, + { + "code": "xh", + "name": "Xhosa" + }, + { + "code": "yi", + "name": "Yiddish" + }, + { + "code": "yo", + "name": "Yoruba" + }, + { + "code": "zu", + "name": "Zulu" + } +] \ No newline at end of file diff --git a/src/pages/settings.css b/src/pages/settings.css index 8ff32ff8..9b1cf279 100644 --- a/src/pages/settings.css +++ b/src/pages/settings.css @@ -59,6 +59,10 @@ #settings-container section > ul > li > div:last-child { text-align: right; } +#settings-container section > ul > li .sub-section { + text-align: left !important; + margin-top: 8px; +} #settings-container div, #settings-container div > * { vertical-align: middle; diff --git a/src/pages/settings.jsx b/src/pages/settings.jsx index ce12ec71..2f06cbfa 100644 --- a/src/pages/settings.jsx +++ b/src/pages/settings.jsx @@ -10,7 +10,10 @@ import Icon from '../components/icon'; import Link from '../components/link'; import NameText from '../components/name-text'; import RelativeTime from '../components/relative-time'; +import targetLanguages from '../data/lingva-target-languages'; import { api } from '../utils/api'; +import getTranslateTargetLanguage from '../utils/get-translate-target-language'; +import localeCode2Text from '../utils/localeCode2Text'; import states from '../utils/states'; import store from '../utils/store'; @@ -33,6 +36,11 @@ function Settings({ onClose }) { const [_, reload] = useReducer((x) => x + 1, 0); + const targetLanguage = + snapStates.settings.contentTranslationTargetLanguage || null; + const systemTargetLanguage = getTranslateTargetLanguage(); + const systemTargetLanguageText = localeCode2Text(systemTargetLanguage); + return (
@@ -240,6 +248,53 @@ function Settings({ onClose }) { Boosts carousel (experimental) +
  • + + {snapStates.settings.contentTranslation && ( +
    + +

    + + Note: This feature uses an external API to translate, + powered by{' '} + + Lingva Translate + + . + +

    +
    + )} +
  • Hidden features

    diff --git a/src/pages/status.jsx b/src/pages/status.jsx index 273df5f9..832e38fd 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -624,6 +624,7 @@ function StatusPage() { instance={instance} withinContext size="l" + enableTranslate /> {uiState !== 'loading' && !authenticated ? ( @@ -700,6 +701,7 @@ function StatusPage() { instance={instance} withinContext size={thread || ancestor ? 'm' : 's'} + enableTranslate /> {/* {replies?.length > LIMIT && (