From 355b3be6e95b2f7e05e8b21819a34c66b651dbb5 Mon Sep 17 00:00:00 2001
From: Lim Chee Aun
Date: Tue, 7 Mar 2023 22:38:06 +0800
Subject: [PATCH] Alrighty, let's test this post translation out!
---
scripts/fetch-lingva-languages.js | 18 +
src/app.css | 6 +
src/components/icon.jsx | 1 +
src/components/loader.css | 1 +
src/components/media-modal.jsx | 60 ++-
src/components/status.jsx | 49 ++
src/components/translation-block.css | 86 ++++
src/components/translation-block.jsx | 154 ++++++
src/data/lingva-source-languages.json | 534 ++++++++++++++++++++
src/data/lingva-target-languages.json | 534 ++++++++++++++++++++
src/pages/settings.css | 4 +
src/pages/settings.jsx | 55 ++
src/pages/status.jsx | 3 +
src/utils/get-translate-target-language.jsx | 24 +
src/utils/localeCode2Text.jsx | 5 +
src/utils/states.js | 10 +
16 files changed, 1530 insertions(+), 14 deletions(-)
create mode 100644 scripts/fetch-lingva-languages.js
create mode 100644 src/components/translation-block.css
create mode 100644 src/components/translation-block.jsx
create mode 100644 src/data/lingva-source-languages.json
create mode 100644 src/data/lingva-target-languages.json
create mode 100644 src/utils/get-translate-target-language.jsx
create mode 100644 src/utils/localeCode2Text.jsx
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({
}
}}
>
-
-
-
-
- {showMediaAlt}
-
-
-
+
)}
>
);
}
+function MediaAltModal({ alt }) {
+ const [forceTranslate, setForceTranslate] = useState(false);
+ return (
+
+
+
+
+ {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 && (
+
+ )}
{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 && (
\n\n')
+ .replace(/<\/li>/g, '\n');
+ div.querySelectorAll('br').forEach((br) => {
+ br.replaceWith('\n');
+ });
+ return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim();
+}
+
export default memo(Status);
diff --git a/src/components/translation-block.css b/src/components/translation-block.css
new file mode 100644
index 00000000..4e3b0d3d
--- /dev/null
+++ b/src/components/translation-block.css
@@ -0,0 +1,86 @@
+.status-translation-block {
+ margin: 8px 0 0;
+ padding: 0;
+ font-size: 90%;
+ border-radius: 8px;
+}
+.status-translation-block summary {
+ list-style: none;
+ display: inline-block;
+}
+.status-translation-block summary::-webkit-details-marker {
+ display: none;
+}
+.status-translation-block summary button {
+ border-radius: 8px;
+ border: 1px solid var(--outline-color);
+ padding: 8px;
+ background-color: var(--bg-color);
+ font-size: 12px;
+ color: var(--text-insignificant-color);
+}
+.status-translation-block summary button:is(:hover, :focus) {
+ color: var(--text-color);
+ filter: none !important;
+}
+.status-translation-block details:not([open]) .detected {
+ display: none;
+}
+/* .status-translation-block details summary button:active, */
+.status-translation-block details[open] summary button {
+ /* color: var(--text-color); */
+ /* background-color: var(--bg-faded-color); */
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom: 0;
+ margin-bottom: -1px;
+ background-image: linear-gradient(
+ to top left,
+ var(--bg-color) 50%,
+ var(--bg-faded-blur-color)
+ );
+ box-shadow: inset 0 0 0 1px var(--bg-color);
+}
+.status-translation-block .translated-block {
+ border: 1px solid var(--outline-color);
+ line-height: 1.3;
+ border-radius: 0 8px 8px 8px;
+ margin: 0;
+ padding: 8px;
+ background-color: var(--bg-color);
+ background-image: linear-gradient(
+ to bottom right,
+ var(--bg-color),
+ var(--bg-faded-blur-color)
+ );
+ white-space: pre-wrap;
+ box-shadow: inset 0 0 0 1px var(--bg-color),
+ 0 1px 5px -2px var(--drop-shadow-color);
+ text-shadow: 0 1px var(--bg-color);
+}
+.status-translation-block .translated-block .translation-info * {
+ vertical-align: middle;
+}
+.status-translation-block .translated-source-select {
+ appearance: none;
+ display: inline-block;
+ margin: 0;
+ padding: 4px 8px;
+ border: 0;
+ border-radius: 8px;
+ background-color: var(--bg-faded-color);
+ color: inherit;
+ width: min-content;
+}
+.status-translation-block .translated-block output {
+ display: block;
+ margin-top: 1em;
+}
+.status-translation-block
+ .translated-block
+ output.translated-pronunciation-content {
+ opacity: 0.75;
+ padding-bottom: 1em;
+ border-top: var(--hairline-width) solid var(--bg-color);
+ border-bottom: var(--hairline-width) solid var(--outline-color);
+}
diff --git a/src/components/translation-block.jsx b/src/components/translation-block.jsx
new file mode 100644
index 00000000..aa7b4479
--- /dev/null
+++ b/src/components/translation-block.jsx
@@ -0,0 +1,154 @@
+import './translation-block.css';
+
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+import sourceLanguages from '../data/lingva-source-languages';
+import getTranslateTargetLanguage from '../utils/get-translate-target-language';
+import localeCode2Text from '../utils/localeCode2Text';
+
+import Icon from './icon';
+import Loader from './loader';
+
+function TranslationBlock({
+ forceTranslate,
+ sourceLanguage,
+ onTranslate,
+ text = '',
+}) {
+ const targetLang = getTranslateTargetLanguage(true);
+ const [uiState, setUIState] = useState('default');
+ const [pronunciationContent, setPronunciationContent] = useState(null);
+ const [translatedContent, setTranslatedContent] = useState(null);
+ const [detectedLang, setDetectedLang] = useState(null);
+ const detailsRef = useRef();
+
+ const sourceLangText = sourceLanguage
+ ? localeCode2Text(sourceLanguage)
+ : null;
+ const targetLangText = localeCode2Text(targetLang);
+ const apiSourceLang = useRef('auto');
+
+ if (!onTranslate)
+ onTranslate = (source, target) => {
+ console.log('TRANSLATE', source, target, text);
+ // Using another API instance instead of lingva.ml because of this bug (slashes don't work):
+ // https://github.com/thedaviddelta/lingva-translate/issues/68
+ return fetch(
+ `https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent(
+ text,
+ )}`,
+ )
+ .then((res) => res.json())
+ .then((res) => {
+ return {
+ provider: 'lingva',
+ content: res.translation,
+ detectedSourceLanguage: res.info.detectedSource,
+ info: res.info,
+ };
+ });
+ // return masto.v1.statuses.translate(id, {
+ // lang: DEFAULT_LANG,
+ // });
+ };
+
+ const translate = async () => {
+ setUIState('loading');
+ const { content, detectedSourceLanguage, provider, ...props } =
+ await onTranslate(apiSourceLang.current, targetLang);
+ if (content) {
+ if (detectedSourceLanguage) {
+ const detectedLangText = localeCode2Text(detectedSourceLanguage);
+ setDetectedLang(detectedLangText);
+ }
+ if (provider === 'lingva') {
+ const pronunciation = props?.info?.pronunciation?.query;
+ if (pronunciation) {
+ setPronunciationContent(pronunciation);
+ }
+ }
+ setTranslatedContent(content);
+ setUIState('default');
+ detailsRef.current.open = true;
+ detailsRef.current.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ });
+ } else {
+ console.error(result);
+ setUIState('error');
+ }
+ };
+
+ useEffect(() => {
+ if (forceTranslate) {
+ translate();
+ }
+ }, [forceTranslate]);
+
+ return (
+
+
+
+
+
+
+
+ {' '}
+ → {targetLangText}
+
+
+ {uiState === 'error' ? (
+
Failed to translate
+ ) : (
+ !!translatedContent && (
+ <>
+ {!!pronunciationContent && (
+
+ )}
+
+ >
+ )
+ )}
+
+
+
+ );
+}
+
+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 && (
@@ -880,6 +882,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) {
instance={instance}
withinContext
size="s"
+ enableTranslate
/>
{!r.replies?.length && r.repliesCount > 0 && (
diff --git a/src/utils/get-translate-target-language.jsx b/src/utils/get-translate-target-language.jsx
new file mode 100644
index 00000000..ce837079
--- /dev/null
+++ b/src/utils/get-translate-target-language.jsx
@@ -0,0 +1,24 @@
+import { match } from '@formatjs/intl-localematcher';
+
+import translationTargetLanguages from '../data/lingva-target-languages';
+
+import states from './states';
+
+function getTranslateTargetLanguage(fromSettings = false) {
+ if (fromSettings) {
+ const { contentTranslationTargetLanguage } = states.settings;
+ if (contentTranslationTargetLanguage) {
+ return contentTranslationTargetLanguage;
+ }
+ }
+ return match(
+ [
+ new Intl.DateTimeFormat().resolvedOptions().locale,
+ ...navigator.languages,
+ ],
+ translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match`
+ 'en',
+ );
+}
+
+export default getTranslateTargetLanguage;
diff --git a/src/utils/localeCode2Text.jsx b/src/utils/localeCode2Text.jsx
new file mode 100644
index 00000000..d3a1b970
--- /dev/null
+++ b/src/utils/localeCode2Text.jsx
@@ -0,0 +1,5 @@
+export default function localeCode2Text(code) {
+ return new Intl.DisplayNames(navigator.languages, {
+ type: 'language',
+ }).of(code);
+}
diff --git a/src/utils/states.js b/src/utils/states.js
index abf6d378..1355114b 100644
--- a/src/utils/states.js
+++ b/src/utils/states.js
@@ -42,6 +42,10 @@ const states = proxy({
shortcutsColumnsMode:
store.account.get('settings-shortcutsColumnsMode') ?? false,
boostsCarousel: store.account.get('settings-boostsCarousel') ?? true,
+ contentTranslation:
+ store.account.get('settings-contentTranslation') ?? true,
+ contentTranslationTargetLanguage:
+ store.account.get('settings-contentTranslationTargetLanguage') || null,
},
});
@@ -63,6 +67,12 @@ subscribe(states, (v) => {
if (path.join('.') === 'settings.shortcutsViewMode') {
store.account.set('settings-shortcutsViewMode', value);
}
+ if (path.join('.') === 'settings.contentTranslation') {
+ store.account.set('settings-contentTranslation', !!value);
+ }
+ if (path.join('.') === 'settings.contentTranslationTargetLanguage') {
+ store.account.set('settings-contentTranslationTargetLanguage', value);
+ }
if (path?.[0] === 'shortcuts') {
store.account.set('shortcuts', states.shortcuts);
}