this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Alrighty, let's test this post translation out!

+1530 -14
+18
scripts/fetch-lingva-languages.js
··· 1 + // Fetch https://lingva.ml/api/v1/languages/{source|target} 2 + import fs from 'fs'; 3 + 4 + fetch('https://lingva.ml/api/v1/languages/source') 5 + .then((response) => response.json()) 6 + .then((json) => { 7 + const file = './src/data/lingva-source-languages.json'; 8 + console.log(`Writing ${file}...`); 9 + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); 10 + }); 11 + 12 + fetch('https://lingva.ml/api/v1/languages/target') 13 + .then((response) => response.json()) 14 + .then((json) => { 15 + const file = './src/data/lingva-target-languages.json'; 16 + console.log(`Writing ${file}...`); 17 + fs.writeFileSync(file, JSON.stringify(json.languages, null, '\t'), 'utf8'); 18 + });
+6
src/app.css
··· 1007 1007 .sheet header :is(h1, h2, h3) { 1008 1008 margin: 0; 1009 1009 } 1010 + .sheet header.header-grid { 1011 + display: grid; 1012 + grid-template-columns: 1fr auto; 1013 + grid-gap: 8px; 1014 + align-items: center; 1015 + } 1010 1016 .sheet main { 1011 1017 overflow: auto; 1012 1018 overflow-x: hidden;
+1
src/components/icon.jsx
··· 63 63 share: 'mingcute:share-2-line', 64 64 sparkles: 'mingcute:sparkles-line', 65 65 exit: 'mingcute:exit-line', 66 + translate: 'mingcute:translate-line', 66 67 }; 67 68 68 69 const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');
+1
src/components/loader.css
··· 6 6 animation: appear 0.3s ease-in-out 1s both; 7 7 vertical-align: middle; 8 8 margin: 8px; 9 + vertical-align: baseline !important; 9 10 } 10 11 .loader-container.abrupt { 11 12 animation: none;
+46 -14
src/components/media-modal.jsx
··· 1 + import { Menu, MenuItem } from '@szhsin/react-menu'; 1 2 import { getBlurHashAverageColor } from 'fast-blurhash'; 2 3 import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks'; 3 4 import { useHotkeys } from 'react-hotkeys-hook'; ··· 6 7 import Link from './link'; 7 8 import Media from './media'; 8 9 import Modal from './modal'; 10 + import TranslationBlock from './translation-block'; 9 11 10 12 function MediaModal({ 11 13 mediaAttachments, ··· 234 236 } 235 237 }} 236 238 > 237 - <div class="sheet"> 238 - <header> 239 - <h2>Media description</h2> 240 - </header> 241 - <main> 242 - <p 243 - style={{ 244 - whiteSpace: 'pre-wrap', 245 - }} 246 - > 247 - {showMediaAlt} 248 - </p> 249 - </main> 250 - </div> 239 + <MediaAltModal alt={showMediaAlt} /> 251 240 </Modal> 252 241 )} 253 242 </> 243 + ); 244 + } 245 + 246 + function MediaAltModal({ alt }) { 247 + const [forceTranslate, setForceTranslate] = useState(false); 248 + return ( 249 + <div class="sheet"> 250 + <header class="header-grid"> 251 + <h2>Media description</h2> 252 + <div class="header-side"> 253 + <Menu 254 + align="end" 255 + menuButton={ 256 + <button type="button" class="plain4"> 257 + <Icon icon="more" alt="More" size="xl" /> 258 + </button> 259 + } 260 + > 261 + <MenuItem 262 + disabled={forceTranslate} 263 + onClick={() => { 264 + setForceTranslate(true); 265 + }} 266 + > 267 + <Icon icon="translate" /> 268 + <span>Translate</span> 269 + </MenuItem> 270 + </Menu> 271 + </div> 272 + </header> 273 + <main> 274 + <p 275 + style={{ 276 + whiteSpace: 'pre-wrap', 277 + }} 278 + > 279 + {alt} 280 + </p> 281 + {forceTranslate && ( 282 + <TranslationBlock forceTranslate={forceTranslate} text={alt} /> 283 + )} 284 + </main> 285 + </div> 254 286 ); 255 287 } 256 288
+49
src/components/status.jsx
··· 20 20 import NameText from '../components/name-text'; 21 21 import { api } from '../utils/api'; 22 22 import enhanceContent from '../utils/enhance-content'; 23 + import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 23 24 import handleContentLinks from '../utils/handle-content-links'; 24 25 import htmlContentLength from '../utils/html-content-length'; 25 26 import niceDateTime from '../utils/nice-date-time'; ··· 35 36 import Media from './media'; 36 37 import MenuLink from './MenuLink'; 37 38 import RelativeTime from './relative-time'; 39 + import TranslationBlock from './translation-block'; 38 40 39 41 const throttle = pThrottle({ 40 42 limit: 1, ··· 66 68 skeleton, 67 69 readOnly, 68 70 contentTextWeight, 71 + enableTranslate, 69 72 }) { 70 73 if (skeleton) { 71 74 return ( ··· 193 196 </div> 194 197 ); 195 198 } 199 + 200 + const [forceTranslate, setForceTranslate] = useState(false); 201 + const targetLanguage = getTranslateTargetLanguage(true); 202 + if (!snapStates.settings.contentTranslation) enableTranslate = false; 196 203 197 204 const [showEdited, setShowEdited] = useState(false); 198 205 ··· 450 457 <Icon icon="link" /> 451 458 <span>Copy link to post</span> 452 459 </MenuItem> 460 + {enableTranslate && ( 461 + <MenuItem 462 + disabled={forceTranslate} 463 + onClick={() => { 464 + setForceTranslate(true); 465 + }} 466 + > 467 + <Icon icon="translate" /> 468 + <span>Translate</span> 469 + </MenuItem> 470 + )} 453 471 {navigator?.share && 454 472 navigator?.canShare?.({ 455 473 url, ··· 770 788 }} 771 789 /> 772 790 )} 791 + {((enableTranslate && 792 + !!content.trim() && 793 + language && 794 + language !== targetLanguage) || 795 + forceTranslate) && ( 796 + <TranslationBlock 797 + forceTranslate={forceTranslate} 798 + sourceLanguage={language} 799 + text={ 800 + (spoilerText ? `${spoilerText}\n\n` : '') + 801 + getHTMLText(content) + 802 + (poll?.options?.length 803 + ? `\n\nPoll:\n${poll.options 804 + .map((option) => `- ${option.title}`) 805 + .join('\n')}` 806 + : '') 807 + } 808 + /> 809 + )} 773 810 {!spoilerText && sensitive && !!mediaAttachments.length && ( 774 811 <button 775 812 class={`plain spoiler ${showSpoiler ? 'spoiling' : ''}`} ··· 1479 1516 } 1480 1517 1481 1518 const unfurlMastodonLink = throttle(_unfurlMastodonLink); 1519 + 1520 + const div = document.createElement('div'); 1521 + function getHTMLText(html) { 1522 + if (!html) return 0; 1523 + div.innerHTML = html 1524 + .replace(/<\/p>/g, '</p>\n\n') 1525 + .replace(/<\/li>/g, '</li>\n'); 1526 + div.querySelectorAll('br').forEach((br) => { 1527 + br.replaceWith('\n'); 1528 + }); 1529 + return div.innerText.replace(/[\r\n]{3,}/g, '\n\n').trim(); 1530 + } 1482 1531 1483 1532 export default memo(Status);
+86
src/components/translation-block.css
··· 1 + .status-translation-block { 2 + margin: 8px 0 0; 3 + padding: 0; 4 + font-size: 90%; 5 + border-radius: 8px; 6 + } 7 + .status-translation-block summary { 8 + list-style: none; 9 + display: inline-block; 10 + } 11 + .status-translation-block summary::-webkit-details-marker { 12 + display: none; 13 + } 14 + .status-translation-block summary button { 15 + border-radius: 8px; 16 + border: 1px solid var(--outline-color); 17 + padding: 8px; 18 + background-color: var(--bg-color); 19 + font-size: 12px; 20 + color: var(--text-insignificant-color); 21 + } 22 + .status-translation-block summary button:is(:hover, :focus) { 23 + color: var(--text-color); 24 + filter: none !important; 25 + } 26 + .status-translation-block details:not([open]) .detected { 27 + display: none; 28 + } 29 + /* .status-translation-block details summary button:active, */ 30 + .status-translation-block details[open] summary button { 31 + /* color: var(--text-color); */ 32 + /* background-color: var(--bg-faded-color); */ 33 + border-bottom-left-radius: 0; 34 + border-bottom-right-radius: 0; 35 + border-bottom: 0; 36 + margin-bottom: -1px; 37 + background-image: linear-gradient( 38 + to top left, 39 + var(--bg-color) 50%, 40 + var(--bg-faded-blur-color) 41 + ); 42 + box-shadow: inset 0 0 0 1px var(--bg-color); 43 + } 44 + .status-translation-block .translated-block { 45 + border: 1px solid var(--outline-color); 46 + line-height: 1.3; 47 + border-radius: 0 8px 8px 8px; 48 + margin: 0; 49 + padding: 8px; 50 + background-color: var(--bg-color); 51 + background-image: linear-gradient( 52 + to bottom right, 53 + var(--bg-color), 54 + var(--bg-faded-blur-color) 55 + ); 56 + white-space: pre-wrap; 57 + box-shadow: inset 0 0 0 1px var(--bg-color), 58 + 0 1px 5px -2px var(--drop-shadow-color); 59 + text-shadow: 0 1px var(--bg-color); 60 + } 61 + .status-translation-block .translated-block .translation-info * { 62 + vertical-align: middle; 63 + } 64 + .status-translation-block .translated-source-select { 65 + appearance: none; 66 + display: inline-block; 67 + margin: 0; 68 + padding: 4px 8px; 69 + border: 0; 70 + border-radius: 8px; 71 + background-color: var(--bg-faded-color); 72 + color: inherit; 73 + width: min-content; 74 + } 75 + .status-translation-block .translated-block output { 76 + display: block; 77 + margin-top: 1em; 78 + } 79 + .status-translation-block 80 + .translated-block 81 + output.translated-pronunciation-content { 82 + opacity: 0.75; 83 + padding-bottom: 1em; 84 + border-top: var(--hairline-width) solid var(--bg-color); 85 + border-bottom: var(--hairline-width) solid var(--outline-color); 86 + }
+154
src/components/translation-block.jsx
··· 1 + import './translation-block.css'; 2 + 3 + import { useEffect, useRef, useState } from 'preact/hooks'; 4 + 5 + import sourceLanguages from '../data/lingva-source-languages'; 6 + import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 7 + import localeCode2Text from '../utils/localeCode2Text'; 8 + 9 + import Icon from './icon'; 10 + import Loader from './loader'; 11 + 12 + function TranslationBlock({ 13 + forceTranslate, 14 + sourceLanguage, 15 + onTranslate, 16 + text = '', 17 + }) { 18 + const targetLang = getTranslateTargetLanguage(true); 19 + const [uiState, setUIState] = useState('default'); 20 + const [pronunciationContent, setPronunciationContent] = useState(null); 21 + const [translatedContent, setTranslatedContent] = useState(null); 22 + const [detectedLang, setDetectedLang] = useState(null); 23 + const detailsRef = useRef(); 24 + 25 + const sourceLangText = sourceLanguage 26 + ? localeCode2Text(sourceLanguage) 27 + : null; 28 + const targetLangText = localeCode2Text(targetLang); 29 + const apiSourceLang = useRef('auto'); 30 + 31 + if (!onTranslate) 32 + onTranslate = (source, target) => { 33 + console.log('TRANSLATE', source, target, text); 34 + // Using another API instance instead of lingva.ml because of this bug (slashes don't work): 35 + // https://github.com/thedaviddelta/lingva-translate/issues/68 36 + return fetch( 37 + `https://lingva.garudalinux.org/api/v1/${source}/${target}/${encodeURIComponent( 38 + text, 39 + )}`, 40 + ) 41 + .then((res) => res.json()) 42 + .then((res) => { 43 + return { 44 + provider: 'lingva', 45 + content: res.translation, 46 + detectedSourceLanguage: res.info.detectedSource, 47 + info: res.info, 48 + }; 49 + }); 50 + // return masto.v1.statuses.translate(id, { 51 + // lang: DEFAULT_LANG, 52 + // }); 53 + }; 54 + 55 + const translate = async () => { 56 + setUIState('loading'); 57 + const { content, detectedSourceLanguage, provider, ...props } = 58 + await onTranslate(apiSourceLang.current, targetLang); 59 + if (content) { 60 + if (detectedSourceLanguage) { 61 + const detectedLangText = localeCode2Text(detectedSourceLanguage); 62 + setDetectedLang(detectedLangText); 63 + } 64 + if (provider === 'lingva') { 65 + const pronunciation = props?.info?.pronunciation?.query; 66 + if (pronunciation) { 67 + setPronunciationContent(pronunciation); 68 + } 69 + } 70 + setTranslatedContent(content); 71 + setUIState('default'); 72 + detailsRef.current.open = true; 73 + detailsRef.current.scrollIntoView({ 74 + behavior: 'smooth', 75 + block: 'nearest', 76 + }); 77 + } else { 78 + console.error(result); 79 + setUIState('error'); 80 + } 81 + }; 82 + 83 + useEffect(() => { 84 + if (forceTranslate) { 85 + translate(); 86 + } 87 + }, [forceTranslate]); 88 + 89 + return ( 90 + <div class="status-translation-block"> 91 + <details ref={detailsRef}> 92 + <summary> 93 + <button 94 + type="button" 95 + onClick={async (e) => { 96 + e.preventDefault(); 97 + e.stopPropagation(); 98 + detailsRef.current.open = !detailsRef.current.open; 99 + if (uiState === 'loading') return; 100 + if (!translatedContent) translate(); 101 + }} 102 + > 103 + <Icon icon="translate" />{' '} 104 + <span> 105 + {uiState === 'loading' 106 + ? 'Translating…' 107 + : sourceLanguage && !detectedLang 108 + ? `Translate from ${sourceLangText}` 109 + : `Translate`} 110 + </span> 111 + </button> 112 + </summary> 113 + <div class="translated-block"> 114 + <div class="translation-info insignificant"> 115 + <select 116 + class="translated-source-select" 117 + disabled={uiState === 'loading'} 118 + onChange={(e) => { 119 + apiSourceLang.current = e.target.value; 120 + translate(); 121 + }} 122 + > 123 + {sourceLanguages.map((l) => ( 124 + <option value={l.code}> 125 + {l.code === 'auto' ? `Auto (${detectedLang ?? '…'})` : l.name} 126 + </option> 127 + ))} 128 + </select>{' '} 129 + <span>→ {targetLangText}</span> 130 + <Loader abrupt hidden={uiState !== 'loading'} /> 131 + </div> 132 + {uiState === 'error' ? ( 133 + <p class="ui-state">Failed to translate</p> 134 + ) : ( 135 + !!translatedContent && ( 136 + <> 137 + {!!pronunciationContent && ( 138 + <output class="translated-pronunciation-content"> 139 + {pronunciationContent} 140 + </output> 141 + )} 142 + <output class="translated-content" lang={targetLang}> 143 + {translatedContent} 144 + </output> 145 + </> 146 + ) 147 + )} 148 + </div> 149 + </details> 150 + </div> 151 + ); 152 + } 153 + 154 + export default TranslationBlock;
+534
src/data/lingva-source-languages.json
··· 1 + [ 2 + { 3 + "code": "auto", 4 + "name": "Detect" 5 + }, 6 + { 7 + "code": "af", 8 + "name": "Afrikaans" 9 + }, 10 + { 11 + "code": "sq", 12 + "name": "Albanian" 13 + }, 14 + { 15 + "code": "am", 16 + "name": "Amharic" 17 + }, 18 + { 19 + "code": "ar", 20 + "name": "Arabic" 21 + }, 22 + { 23 + "code": "hy", 24 + "name": "Armenian" 25 + }, 26 + { 27 + "code": "as", 28 + "name": "Assamese" 29 + }, 30 + { 31 + "code": "ay", 32 + "name": "Aymara" 33 + }, 34 + { 35 + "code": "az", 36 + "name": "Azerbaijani" 37 + }, 38 + { 39 + "code": "bm", 40 + "name": "Bambara" 41 + }, 42 + { 43 + "code": "eu", 44 + "name": "Basque" 45 + }, 46 + { 47 + "code": "be", 48 + "name": "Belarusian" 49 + }, 50 + { 51 + "code": "bn", 52 + "name": "Bengali" 53 + }, 54 + { 55 + "code": "bho", 56 + "name": "Bhojpuri" 57 + }, 58 + { 59 + "code": "bs", 60 + "name": "Bosnian" 61 + }, 62 + { 63 + "code": "bg", 64 + "name": "Bulgarian" 65 + }, 66 + { 67 + "code": "ca", 68 + "name": "Catalan" 69 + }, 70 + { 71 + "code": "ceb", 72 + "name": "Cebuano" 73 + }, 74 + { 75 + "code": "ny", 76 + "name": "Chichewa" 77 + }, 78 + { 79 + "code": "zh", 80 + "name": "Chinese" 81 + }, 82 + { 83 + "code": "co", 84 + "name": "Corsican" 85 + }, 86 + { 87 + "code": "hr", 88 + "name": "Croatian" 89 + }, 90 + { 91 + "code": "cs", 92 + "name": "Czech" 93 + }, 94 + { 95 + "code": "da", 96 + "name": "Danish" 97 + }, 98 + { 99 + "code": "dv", 100 + "name": "Dhivehi" 101 + }, 102 + { 103 + "code": "doi", 104 + "name": "Dogri" 105 + }, 106 + { 107 + "code": "nl", 108 + "name": "Dutch" 109 + }, 110 + { 111 + "code": "en", 112 + "name": "English" 113 + }, 114 + { 115 + "code": "eo", 116 + "name": "Esperanto" 117 + }, 118 + { 119 + "code": "et", 120 + "name": "Estonian" 121 + }, 122 + { 123 + "code": "ee", 124 + "name": "Ewe" 125 + }, 126 + { 127 + "code": "tl", 128 + "name": "Filipino" 129 + }, 130 + { 131 + "code": "fi", 132 + "name": "Finnish" 133 + }, 134 + { 135 + "code": "fr", 136 + "name": "French" 137 + }, 138 + { 139 + "code": "fy", 140 + "name": "Frisian" 141 + }, 142 + { 143 + "code": "gl", 144 + "name": "Galician" 145 + }, 146 + { 147 + "code": "ka", 148 + "name": "Georgian" 149 + }, 150 + { 151 + "code": "de", 152 + "name": "German" 153 + }, 154 + { 155 + "code": "el", 156 + "name": "Greek" 157 + }, 158 + { 159 + "code": "gn", 160 + "name": "Guarani" 161 + }, 162 + { 163 + "code": "gu", 164 + "name": "Gujarati" 165 + }, 166 + { 167 + "code": "ht", 168 + "name": "Haitian Creole" 169 + }, 170 + { 171 + "code": "ha", 172 + "name": "Hausa" 173 + }, 174 + { 175 + "code": "haw", 176 + "name": "Hawaiian" 177 + }, 178 + { 179 + "code": "iw", 180 + "name": "Hebrew" 181 + }, 182 + { 183 + "code": "hi", 184 + "name": "Hindi" 185 + }, 186 + { 187 + "code": "hmn", 188 + "name": "Hmong" 189 + }, 190 + { 191 + "code": "hu", 192 + "name": "Hungarian" 193 + }, 194 + { 195 + "code": "is", 196 + "name": "Icelandic" 197 + }, 198 + { 199 + "code": "ig", 200 + "name": "Igbo" 201 + }, 202 + { 203 + "code": "ilo", 204 + "name": "Ilocano" 205 + }, 206 + { 207 + "code": "id", 208 + "name": "Indonesian" 209 + }, 210 + { 211 + "code": "ga", 212 + "name": "Irish" 213 + }, 214 + { 215 + "code": "it", 216 + "name": "Italian" 217 + }, 218 + { 219 + "code": "ja", 220 + "name": "Japanese" 221 + }, 222 + { 223 + "code": "jw", 224 + "name": "Javanese" 225 + }, 226 + { 227 + "code": "kn", 228 + "name": "Kannada" 229 + }, 230 + { 231 + "code": "kk", 232 + "name": "Kazakh" 233 + }, 234 + { 235 + "code": "km", 236 + "name": "Khmer" 237 + }, 238 + { 239 + "code": "rw", 240 + "name": "Kinyarwanda" 241 + }, 242 + { 243 + "code": "gom", 244 + "name": "Konkani" 245 + }, 246 + { 247 + "code": "ko", 248 + "name": "Korean" 249 + }, 250 + { 251 + "code": "kri", 252 + "name": "Krio" 253 + }, 254 + { 255 + "code": "ku", 256 + "name": "Kurdish (Kurmanji)" 257 + }, 258 + { 259 + "code": "ckb", 260 + "name": "Kurdish (Sorani)" 261 + }, 262 + { 263 + "code": "ky", 264 + "name": "Kyrgyz" 265 + }, 266 + { 267 + "code": "lo", 268 + "name": "Lao" 269 + }, 270 + { 271 + "code": "la", 272 + "name": "Latin" 273 + }, 274 + { 275 + "code": "lv", 276 + "name": "Latvian" 277 + }, 278 + { 279 + "code": "ln", 280 + "name": "Lingala" 281 + }, 282 + { 283 + "code": "lt", 284 + "name": "Lithuanian" 285 + }, 286 + { 287 + "code": "lg", 288 + "name": "Luganda" 289 + }, 290 + { 291 + "code": "lb", 292 + "name": "Luxembourgish" 293 + }, 294 + { 295 + "code": "mk", 296 + "name": "Macedonian" 297 + }, 298 + { 299 + "code": "mai", 300 + "name": "Maithili" 301 + }, 302 + { 303 + "code": "mg", 304 + "name": "Malagasy" 305 + }, 306 + { 307 + "code": "ms", 308 + "name": "Malay" 309 + }, 310 + { 311 + "code": "ml", 312 + "name": "Malayalam" 313 + }, 314 + { 315 + "code": "mt", 316 + "name": "Maltese" 317 + }, 318 + { 319 + "code": "mi", 320 + "name": "Maori" 321 + }, 322 + { 323 + "code": "mr", 324 + "name": "Marathi" 325 + }, 326 + { 327 + "code": "mni-Mtei", 328 + "name": "Meiteilon (Manipuri)" 329 + }, 330 + { 331 + "code": "lus", 332 + "name": "Mizo" 333 + }, 334 + { 335 + "code": "mn", 336 + "name": "Mongolian" 337 + }, 338 + { 339 + "code": "my", 340 + "name": "Myanmar (Burmese)" 341 + }, 342 + { 343 + "code": "ne", 344 + "name": "Nepali" 345 + }, 346 + { 347 + "code": "no", 348 + "name": "Norwegian" 349 + }, 350 + { 351 + "code": "or", 352 + "name": "Odia (Oriya)" 353 + }, 354 + { 355 + "code": "om", 356 + "name": "Oromo" 357 + }, 358 + { 359 + "code": "ps", 360 + "name": "Pashto" 361 + }, 362 + { 363 + "code": "fa", 364 + "name": "Persian" 365 + }, 366 + { 367 + "code": "pl", 368 + "name": "Polish" 369 + }, 370 + { 371 + "code": "pt", 372 + "name": "Portuguese" 373 + }, 374 + { 375 + "code": "pa", 376 + "name": "Punjabi" 377 + }, 378 + { 379 + "code": "qu", 380 + "name": "Quechua" 381 + }, 382 + { 383 + "code": "ro", 384 + "name": "Romanian" 385 + }, 386 + { 387 + "code": "ru", 388 + "name": "Russian" 389 + }, 390 + { 391 + "code": "sm", 392 + "name": "Samoan" 393 + }, 394 + { 395 + "code": "sa", 396 + "name": "Sanskrit" 397 + }, 398 + { 399 + "code": "gd", 400 + "name": "Scots Gaelic" 401 + }, 402 + { 403 + "code": "nso", 404 + "name": "Sepedi" 405 + }, 406 + { 407 + "code": "sr", 408 + "name": "Serbian" 409 + }, 410 + { 411 + "code": "st", 412 + "name": "Sesotho" 413 + }, 414 + { 415 + "code": "sn", 416 + "name": "Shona" 417 + }, 418 + { 419 + "code": "sd", 420 + "name": "Sindhi" 421 + }, 422 + { 423 + "code": "si", 424 + "name": "Sinhala" 425 + }, 426 + { 427 + "code": "sk", 428 + "name": "Slovak" 429 + }, 430 + { 431 + "code": "sl", 432 + "name": "Slovenian" 433 + }, 434 + { 435 + "code": "so", 436 + "name": "Somali" 437 + }, 438 + { 439 + "code": "es", 440 + "name": "Spanish" 441 + }, 442 + { 443 + "code": "su", 444 + "name": "Sundanese" 445 + }, 446 + { 447 + "code": "sw", 448 + "name": "Swahili" 449 + }, 450 + { 451 + "code": "sv", 452 + "name": "Swedish" 453 + }, 454 + { 455 + "code": "tg", 456 + "name": "Tajik" 457 + }, 458 + { 459 + "code": "ta", 460 + "name": "Tamil" 461 + }, 462 + { 463 + "code": "tt", 464 + "name": "Tatar" 465 + }, 466 + { 467 + "code": "te", 468 + "name": "Telugu" 469 + }, 470 + { 471 + "code": "th", 472 + "name": "Thai" 473 + }, 474 + { 475 + "code": "ti", 476 + "name": "Tigrinya" 477 + }, 478 + { 479 + "code": "ts", 480 + "name": "Tsonga" 481 + }, 482 + { 483 + "code": "tr", 484 + "name": "Turkish" 485 + }, 486 + { 487 + "code": "tk", 488 + "name": "Turkmen" 489 + }, 490 + { 491 + "code": "ak", 492 + "name": "Twi" 493 + }, 494 + { 495 + "code": "uk", 496 + "name": "Ukrainian" 497 + }, 498 + { 499 + "code": "ur", 500 + "name": "Urdu" 501 + }, 502 + { 503 + "code": "ug", 504 + "name": "Uyghur" 505 + }, 506 + { 507 + "code": "uz", 508 + "name": "Uzbek" 509 + }, 510 + { 511 + "code": "vi", 512 + "name": "Vietnamese" 513 + }, 514 + { 515 + "code": "cy", 516 + "name": "Welsh" 517 + }, 518 + { 519 + "code": "xh", 520 + "name": "Xhosa" 521 + }, 522 + { 523 + "code": "yi", 524 + "name": "Yiddish" 525 + }, 526 + { 527 + "code": "yo", 528 + "name": "Yoruba" 529 + }, 530 + { 531 + "code": "zu", 532 + "name": "Zulu" 533 + } 534 + ]
+534
src/data/lingva-target-languages.json
··· 1 + [ 2 + { 3 + "code": "af", 4 + "name": "Afrikaans" 5 + }, 6 + { 7 + "code": "sq", 8 + "name": "Albanian" 9 + }, 10 + { 11 + "code": "am", 12 + "name": "Amharic" 13 + }, 14 + { 15 + "code": "ar", 16 + "name": "Arabic" 17 + }, 18 + { 19 + "code": "hy", 20 + "name": "Armenian" 21 + }, 22 + { 23 + "code": "as", 24 + "name": "Assamese" 25 + }, 26 + { 27 + "code": "ay", 28 + "name": "Aymara" 29 + }, 30 + { 31 + "code": "az", 32 + "name": "Azerbaijani" 33 + }, 34 + { 35 + "code": "bm", 36 + "name": "Bambara" 37 + }, 38 + { 39 + "code": "eu", 40 + "name": "Basque" 41 + }, 42 + { 43 + "code": "be", 44 + "name": "Belarusian" 45 + }, 46 + { 47 + "code": "bn", 48 + "name": "Bengali" 49 + }, 50 + { 51 + "code": "bho", 52 + "name": "Bhojpuri" 53 + }, 54 + { 55 + "code": "bs", 56 + "name": "Bosnian" 57 + }, 58 + { 59 + "code": "bg", 60 + "name": "Bulgarian" 61 + }, 62 + { 63 + "code": "ca", 64 + "name": "Catalan" 65 + }, 66 + { 67 + "code": "ceb", 68 + "name": "Cebuano" 69 + }, 70 + { 71 + "code": "ny", 72 + "name": "Chichewa" 73 + }, 74 + { 75 + "code": "zh", 76 + "name": "Chinese" 77 + }, 78 + { 79 + "code": "zh_HANT", 80 + "name": "Chinese (Traditional)" 81 + }, 82 + { 83 + "code": "co", 84 + "name": "Corsican" 85 + }, 86 + { 87 + "code": "hr", 88 + "name": "Croatian" 89 + }, 90 + { 91 + "code": "cs", 92 + "name": "Czech" 93 + }, 94 + { 95 + "code": "da", 96 + "name": "Danish" 97 + }, 98 + { 99 + "code": "dv", 100 + "name": "Dhivehi" 101 + }, 102 + { 103 + "code": "doi", 104 + "name": "Dogri" 105 + }, 106 + { 107 + "code": "nl", 108 + "name": "Dutch" 109 + }, 110 + { 111 + "code": "en", 112 + "name": "English" 113 + }, 114 + { 115 + "code": "eo", 116 + "name": "Esperanto" 117 + }, 118 + { 119 + "code": "et", 120 + "name": "Estonian" 121 + }, 122 + { 123 + "code": "ee", 124 + "name": "Ewe" 125 + }, 126 + { 127 + "code": "tl", 128 + "name": "Filipino" 129 + }, 130 + { 131 + "code": "fi", 132 + "name": "Finnish" 133 + }, 134 + { 135 + "code": "fr", 136 + "name": "French" 137 + }, 138 + { 139 + "code": "fy", 140 + "name": "Frisian" 141 + }, 142 + { 143 + "code": "gl", 144 + "name": "Galician" 145 + }, 146 + { 147 + "code": "ka", 148 + "name": "Georgian" 149 + }, 150 + { 151 + "code": "de", 152 + "name": "German" 153 + }, 154 + { 155 + "code": "el", 156 + "name": "Greek" 157 + }, 158 + { 159 + "code": "gn", 160 + "name": "Guarani" 161 + }, 162 + { 163 + "code": "gu", 164 + "name": "Gujarati" 165 + }, 166 + { 167 + "code": "ht", 168 + "name": "Haitian Creole" 169 + }, 170 + { 171 + "code": "ha", 172 + "name": "Hausa" 173 + }, 174 + { 175 + "code": "haw", 176 + "name": "Hawaiian" 177 + }, 178 + { 179 + "code": "iw", 180 + "name": "Hebrew" 181 + }, 182 + { 183 + "code": "hi", 184 + "name": "Hindi" 185 + }, 186 + { 187 + "code": "hmn", 188 + "name": "Hmong" 189 + }, 190 + { 191 + "code": "hu", 192 + "name": "Hungarian" 193 + }, 194 + { 195 + "code": "is", 196 + "name": "Icelandic" 197 + }, 198 + { 199 + "code": "ig", 200 + "name": "Igbo" 201 + }, 202 + { 203 + "code": "ilo", 204 + "name": "Ilocano" 205 + }, 206 + { 207 + "code": "id", 208 + "name": "Indonesian" 209 + }, 210 + { 211 + "code": "ga", 212 + "name": "Irish" 213 + }, 214 + { 215 + "code": "it", 216 + "name": "Italian" 217 + }, 218 + { 219 + "code": "ja", 220 + "name": "Japanese" 221 + }, 222 + { 223 + "code": "jw", 224 + "name": "Javanese" 225 + }, 226 + { 227 + "code": "kn", 228 + "name": "Kannada" 229 + }, 230 + { 231 + "code": "kk", 232 + "name": "Kazakh" 233 + }, 234 + { 235 + "code": "km", 236 + "name": "Khmer" 237 + }, 238 + { 239 + "code": "rw", 240 + "name": "Kinyarwanda" 241 + }, 242 + { 243 + "code": "gom", 244 + "name": "Konkani" 245 + }, 246 + { 247 + "code": "ko", 248 + "name": "Korean" 249 + }, 250 + { 251 + "code": "kri", 252 + "name": "Krio" 253 + }, 254 + { 255 + "code": "ku", 256 + "name": "Kurdish (Kurmanji)" 257 + }, 258 + { 259 + "code": "ckb", 260 + "name": "Kurdish (Sorani)" 261 + }, 262 + { 263 + "code": "ky", 264 + "name": "Kyrgyz" 265 + }, 266 + { 267 + "code": "lo", 268 + "name": "Lao" 269 + }, 270 + { 271 + "code": "la", 272 + "name": "Latin" 273 + }, 274 + { 275 + "code": "lv", 276 + "name": "Latvian" 277 + }, 278 + { 279 + "code": "ln", 280 + "name": "Lingala" 281 + }, 282 + { 283 + "code": "lt", 284 + "name": "Lithuanian" 285 + }, 286 + { 287 + "code": "lg", 288 + "name": "Luganda" 289 + }, 290 + { 291 + "code": "lb", 292 + "name": "Luxembourgish" 293 + }, 294 + { 295 + "code": "mk", 296 + "name": "Macedonian" 297 + }, 298 + { 299 + "code": "mai", 300 + "name": "Maithili" 301 + }, 302 + { 303 + "code": "mg", 304 + "name": "Malagasy" 305 + }, 306 + { 307 + "code": "ms", 308 + "name": "Malay" 309 + }, 310 + { 311 + "code": "ml", 312 + "name": "Malayalam" 313 + }, 314 + { 315 + "code": "mt", 316 + "name": "Maltese" 317 + }, 318 + { 319 + "code": "mi", 320 + "name": "Maori" 321 + }, 322 + { 323 + "code": "mr", 324 + "name": "Marathi" 325 + }, 326 + { 327 + "code": "mni-Mtei", 328 + "name": "Meiteilon (Manipuri)" 329 + }, 330 + { 331 + "code": "lus", 332 + "name": "Mizo" 333 + }, 334 + { 335 + "code": "mn", 336 + "name": "Mongolian" 337 + }, 338 + { 339 + "code": "my", 340 + "name": "Myanmar (Burmese)" 341 + }, 342 + { 343 + "code": "ne", 344 + "name": "Nepali" 345 + }, 346 + { 347 + "code": "no", 348 + "name": "Norwegian" 349 + }, 350 + { 351 + "code": "or", 352 + "name": "Odia (Oriya)" 353 + }, 354 + { 355 + "code": "om", 356 + "name": "Oromo" 357 + }, 358 + { 359 + "code": "ps", 360 + "name": "Pashto" 361 + }, 362 + { 363 + "code": "fa", 364 + "name": "Persian" 365 + }, 366 + { 367 + "code": "pl", 368 + "name": "Polish" 369 + }, 370 + { 371 + "code": "pt", 372 + "name": "Portuguese" 373 + }, 374 + { 375 + "code": "pa", 376 + "name": "Punjabi" 377 + }, 378 + { 379 + "code": "qu", 380 + "name": "Quechua" 381 + }, 382 + { 383 + "code": "ro", 384 + "name": "Romanian" 385 + }, 386 + { 387 + "code": "ru", 388 + "name": "Russian" 389 + }, 390 + { 391 + "code": "sm", 392 + "name": "Samoan" 393 + }, 394 + { 395 + "code": "sa", 396 + "name": "Sanskrit" 397 + }, 398 + { 399 + "code": "gd", 400 + "name": "Scots Gaelic" 401 + }, 402 + { 403 + "code": "nso", 404 + "name": "Sepedi" 405 + }, 406 + { 407 + "code": "sr", 408 + "name": "Serbian" 409 + }, 410 + { 411 + "code": "st", 412 + "name": "Sesotho" 413 + }, 414 + { 415 + "code": "sn", 416 + "name": "Shona" 417 + }, 418 + { 419 + "code": "sd", 420 + "name": "Sindhi" 421 + }, 422 + { 423 + "code": "si", 424 + "name": "Sinhala" 425 + }, 426 + { 427 + "code": "sk", 428 + "name": "Slovak" 429 + }, 430 + { 431 + "code": "sl", 432 + "name": "Slovenian" 433 + }, 434 + { 435 + "code": "so", 436 + "name": "Somali" 437 + }, 438 + { 439 + "code": "es", 440 + "name": "Spanish" 441 + }, 442 + { 443 + "code": "su", 444 + "name": "Sundanese" 445 + }, 446 + { 447 + "code": "sw", 448 + "name": "Swahili" 449 + }, 450 + { 451 + "code": "sv", 452 + "name": "Swedish" 453 + }, 454 + { 455 + "code": "tg", 456 + "name": "Tajik" 457 + }, 458 + { 459 + "code": "ta", 460 + "name": "Tamil" 461 + }, 462 + { 463 + "code": "tt", 464 + "name": "Tatar" 465 + }, 466 + { 467 + "code": "te", 468 + "name": "Telugu" 469 + }, 470 + { 471 + "code": "th", 472 + "name": "Thai" 473 + }, 474 + { 475 + "code": "ti", 476 + "name": "Tigrinya" 477 + }, 478 + { 479 + "code": "ts", 480 + "name": "Tsonga" 481 + }, 482 + { 483 + "code": "tr", 484 + "name": "Turkish" 485 + }, 486 + { 487 + "code": "tk", 488 + "name": "Turkmen" 489 + }, 490 + { 491 + "code": "ak", 492 + "name": "Twi" 493 + }, 494 + { 495 + "code": "uk", 496 + "name": "Ukrainian" 497 + }, 498 + { 499 + "code": "ur", 500 + "name": "Urdu" 501 + }, 502 + { 503 + "code": "ug", 504 + "name": "Uyghur" 505 + }, 506 + { 507 + "code": "uz", 508 + "name": "Uzbek" 509 + }, 510 + { 511 + "code": "vi", 512 + "name": "Vietnamese" 513 + }, 514 + { 515 + "code": "cy", 516 + "name": "Welsh" 517 + }, 518 + { 519 + "code": "xh", 520 + "name": "Xhosa" 521 + }, 522 + { 523 + "code": "yi", 524 + "name": "Yiddish" 525 + }, 526 + { 527 + "code": "yo", 528 + "name": "Yoruba" 529 + }, 530 + { 531 + "code": "zu", 532 + "name": "Zulu" 533 + } 534 + ]
+4
src/pages/settings.css
··· 59 59 #settings-container section > ul > li > div:last-child { 60 60 text-align: right; 61 61 } 62 + #settings-container section > ul > li .sub-section { 63 + text-align: left !important; 64 + margin-top: 8px; 65 + } 62 66 #settings-container div, 63 67 #settings-container div > * { 64 68 vertical-align: middle;
+55
src/pages/settings.jsx
··· 10 10 import Link from '../components/link'; 11 11 import NameText from '../components/name-text'; 12 12 import RelativeTime from '../components/relative-time'; 13 + import targetLanguages from '../data/lingva-target-languages'; 13 14 import { api } from '../utils/api'; 15 + import getTranslateTargetLanguage from '../utils/get-translate-target-language'; 16 + import localeCode2Text from '../utils/localeCode2Text'; 14 17 import states from '../utils/states'; 15 18 import store from '../utils/store'; 16 19 ··· 32 35 const [currentDefault, setCurrentDefault] = useState(0); 33 36 34 37 const [_, reload] = useReducer((x) => x + 1, 0); 38 + 39 + const targetLanguage = 40 + snapStates.settings.contentTranslationTargetLanguage || null; 41 + const systemTargetLanguage = getTranslateTargetLanguage(); 42 + const systemTargetLanguageText = localeCode2Text(systemTargetLanguage); 35 43 36 44 return ( 37 45 <div id="settings-container" class="sheet" tabIndex="-1"> ··· 239 247 />{' '} 240 248 Boosts carousel (experimental) 241 249 </label> 250 + </li> 251 + <li> 252 + <label> 253 + <input 254 + type="checkbox" 255 + checked={snapStates.settings.contentTranslation} 256 + onChange={(e) => { 257 + states.settings.contentTranslation = e.target.checked; 258 + }} 259 + />{' '} 260 + Post translation (experimental) 261 + </label> 262 + {snapStates.settings.contentTranslation && ( 263 + <div class="sub-section"> 264 + <label> 265 + Translate to{' '} 266 + <select 267 + value={targetLanguage} 268 + onChange={(e) => { 269 + states.settings.contentTranslationTargetLanguage = 270 + e.target.value || null; 271 + }} 272 + > 273 + <option value=""> 274 + System language ({systemTargetLanguageText}) 275 + </option> 276 + <option disabled>──────────</option> 277 + {targetLanguages.map((lang) => ( 278 + <option value={lang.code}>{lang.name}</option> 279 + ))} 280 + </select> 281 + </label> 282 + <p> 283 + <small> 284 + Note: This feature uses an external API to translate, 285 + powered by{' '} 286 + <a 287 + href="https://github.com/thedaviddelta/lingva-translate" 288 + target="_blank" 289 + > 290 + Lingva Translate 291 + </a> 292 + . 293 + </small> 294 + </p> 295 + </div> 296 + )} 242 297 </li> 243 298 </ul> 244 299 </section>
+3
src/pages/status.jsx
··· 624 624 instance={instance} 625 625 withinContext 626 626 size="l" 627 + enableTranslate 627 628 /> 628 629 </InView> 629 630 {uiState !== 'loading' && !authenticated ? ( ··· 700 701 instance={instance} 701 702 withinContext 702 703 size={thread || ancestor ? 'm' : 's'} 704 + enableTranslate 703 705 /> 704 706 {/* {replies?.length > LIMIT && ( 705 707 <div class="replies-link"> ··· 880 882 instance={instance} 881 883 withinContext 882 884 size="s" 885 + enableTranslate 883 886 /> 884 887 {!r.replies?.length && r.repliesCount > 0 && ( 885 888 <div class="replies-link">
+24
src/utils/get-translate-target-language.jsx
··· 1 + import { match } from '@formatjs/intl-localematcher'; 2 + 3 + import translationTargetLanguages from '../data/lingva-target-languages'; 4 + 5 + import states from './states'; 6 + 7 + function getTranslateTargetLanguage(fromSettings = false) { 8 + if (fromSettings) { 9 + const { contentTranslationTargetLanguage } = states.settings; 10 + if (contentTranslationTargetLanguage) { 11 + return contentTranslationTargetLanguage; 12 + } 13 + } 14 + return match( 15 + [ 16 + new Intl.DateTimeFormat().resolvedOptions().locale, 17 + ...navigator.languages, 18 + ], 19 + translationTargetLanguages.map((l) => l.code.replace('_', '-')), // The underscore will fail Intl.Locale inside `match` 20 + 'en', 21 + ); 22 + } 23 + 24 + export default getTranslateTargetLanguage;
+5
src/utils/localeCode2Text.jsx
··· 1 + export default function localeCode2Text(code) { 2 + return new Intl.DisplayNames(navigator.languages, { 3 + type: 'language', 4 + }).of(code); 5 + }
+10
src/utils/states.js
··· 42 42 shortcutsColumnsMode: 43 43 store.account.get('settings-shortcutsColumnsMode') ?? false, 44 44 boostsCarousel: store.account.get('settings-boostsCarousel') ?? true, 45 + contentTranslation: 46 + store.account.get('settings-contentTranslation') ?? true, 47 + contentTranslationTargetLanguage: 48 + store.account.get('settings-contentTranslationTargetLanguage') || null, 45 49 }, 46 50 }); 47 51 ··· 62 66 } 63 67 if (path.join('.') === 'settings.shortcutsViewMode') { 64 68 store.account.set('settings-shortcutsViewMode', value); 69 + } 70 + if (path.join('.') === 'settings.contentTranslation') { 71 + store.account.set('settings-contentTranslation', !!value); 72 + } 73 + if (path.join('.') === 'settings.contentTranslationTargetLanguage') { 74 + store.account.set('settings-contentTranslationTargetLanguage', value); 65 75 } 66 76 if (path?.[0] === 'shortcuts') { 67 77 store.account.set('shortcuts', states.shortcuts);