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({ } }} > - <div class="sheet"> - <header> - <h2>Media description</h2> - </header> - <main> - <p - style={{ - whiteSpace: 'pre-wrap', - }} - > - {showMediaAlt} - </p> - </main> - </div> + <MediaAltModal alt={showMediaAlt} /> </Modal> )} </> ); } +function MediaAltModal({ alt }) { + const [forceTranslate, setForceTranslate] = useState(false); + return ( + <div class="sheet"> + <header class="header-grid"> + <h2>Media description</h2> + <div class="header-side"> + <Menu + align="end" + menuButton={ + <button type="button" class="plain4"> + <Icon icon="more" alt="More" size="xl" /> + </button> + } + > + <MenuItem + disabled={forceTranslate} + onClick={() => { + setForceTranslate(true); + }} + > + <Icon icon="translate" /> + <span>Translate</span> + </MenuItem> + </Menu> + </div> + </header> + <main> + <p + style={{ + whiteSpace: 'pre-wrap', + }} + > + {alt} + </p> + {forceTranslate && ( + <TranslationBlock forceTranslate={forceTranslate} text={alt} /> + )} + </main> + </div> + ); +} + 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({ <Icon icon="link" /> <span>Copy link to post</span> </MenuItem> + {enableTranslate && ( + <MenuItem + disabled={forceTranslate} + onClick={() => { + setForceTranslate(true); + }} + > + <Icon icon="translate" /> + <span>Translate</span> + </MenuItem> + )} {navigator?.share && navigator?.canShare?.({ url, @@ -770,6 +788,25 @@ function Status({ }} /> )} + {((enableTranslate && + !!content.trim() && + language && + language !== targetLanguage) || + forceTranslate) && ( + <TranslationBlock + forceTranslate={forceTranslate} + sourceLanguage={language} + text={ + (spoilerText ? `${spoilerText}\n\n` : '') + + getHTMLText(content) + + (poll?.options?.length + ? `\n\nPoll:\n${poll.options + .map((option) => `- ${option.title}`) + .join('\n')}` + : '') + } + /> + )} {!spoilerText && sensitive && !!mediaAttachments.length && ( <button class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`} @@ -1480,4 +1517,16 @@ function _unfurlMastodonLink(instance, url) { const unfurlMastodonLink = throttle(_unfurlMastodonLink); +const div = document.createElement('div'); +function getHTMLText(html) { + if (!html) return 0; + div.innerHTML = html + .replace(/<\/p>/g, '</p>\n\n') + .replace(/<\/li>/g, '</li>\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 ( + <div class="status-translation-block"> + <details ref={detailsRef}> + <summary> + <button + type="button" + onClick={async (e) => { + e.preventDefault(); + e.stopPropagation(); + detailsRef.current.open = !detailsRef.current.open; + if (uiState === 'loading') return; + if (!translatedContent) translate(); + }} + > + <Icon icon="translate" />{' '} + <span> + {uiState === 'loading' + ? 'Translating…' + : sourceLanguage && !detectedLang + ? `Translate from ${sourceLangText}` + : `Translate`} + </span> + </button> + </summary> + <div class="translated-block"> + <div class="translation-info insignificant"> + <select + class="translated-source-select" + disabled={uiState === 'loading'} + onChange={(e) => { + apiSourceLang.current = e.target.value; + translate(); + }} + > + {sourceLanguages.map((l) => ( + <option value={l.code}> + {l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name} + </option> + ))} + </select>{' '} + <span>→ {targetLangText}</span> + <Loader abrupt hidden={uiState !== 'loading'} /> + </div> + {uiState === 'error' ? ( + <p class="ui-state">Failed to translate</p> + ) : ( + !!translatedContent && ( + <> + {!!pronunciationContent && ( + <output class="translated-pronunciation-content"> + {pronunciationContent} + </output> + )} + <output class="translated-content" lang={targetLang}> + {translatedContent} + </output> + </> + ) + )} + </div> + </details> + </div> + ); +} + +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 ( <div id="settings-container" class="sheet" tabIndex="-1"> <main> @@ -240,6 +248,53 @@ function Settings({ onClose }) { Boosts carousel (experimental) </label> </li> + <li> + <label> + <input + type="checkbox" + checked={snapStates.settings.contentTranslation} + onChange={(e) => { + states.settings.contentTranslation = e.target.checked; + }} + />{' '} + Post translation (experimental) + </label> + {snapStates.settings.contentTranslation && ( + <div class="sub-section"> + <label> + Translate to{' '} + <select + value={targetLanguage} + onChange={(e) => { + states.settings.contentTranslationTargetLanguage = + e.target.value || null; + }} + > + <option value=""> + System language ({systemTargetLanguageText}) + </option> + <option disabled>──────────</option> + {targetLanguages.map((lang) => ( + <option value={lang.code}>{lang.name}</option> + ))} + </select> + </label> + <p> + <small> + Note: This feature uses an external API to translate, + powered by{' '} + <a + href="https://github.com/thedaviddelta/lingva-translate" + target="_blank" + > + Lingva Translate + </a> + . + </small> + </p> + </div> + )} + </li> </ul> </section> <h2>Hidden features</h2> 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 /> </InView> {uiState !== 'loading' && !authenticated ? ( @@ -700,6 +701,7 @@ function StatusPage() { instance={instance} withinContext size={thread || ancestor ? 'm' : 's'} + enableTranslate /> {/* {replies?.length > LIMIT && ( <div class="replies-link"> @@ -880,6 +882,7 @@ function SubComments({ hasManyStatuses, replies, instance, hasParentThread }) { instance={instance} withinContext size="s" + enableTranslate /> {!r.replies?.length && r.repliesCount > 0 && ( <div class="replies-link"> 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); }