Bluesky app fork with some witchin' additions 馃挮
at main 374 lines 9.8 kB view raw
1import {type AppBskyFeedDefs, AppBskyFeedPost} from '@atproto/api' 2import * as bcp47Match from 'bcp-47-match' 3import lande from 'lande' 4 5import {hasProp} from '#/lib/type-guards' 6import * as persisted from '#/state/persisted' 7import { 8 AppLanguage, 9 type Language, 10 LANGUAGES_MAP_CODE2, 11 LANGUAGES_MAP_CODE3, 12} from './languages' 13 14export function code2ToCode3(lang: string): string { 15 if (lang.length === 2) { 16 return LANGUAGES_MAP_CODE2[lang]?.code3 || lang 17 } 18 return lang 19} 20 21export function code3ToCode2(lang: string): string { 22 if (lang.length === 3) { 23 return LANGUAGES_MAP_CODE3[lang]?.code2 || lang 24 } 25 return lang 26} 27 28export function code3ToCode2Strict(lang: string): string | undefined { 29 if (lang.length === 3) { 30 return LANGUAGES_MAP_CODE3[lang]?.code2 31 } 32 33 return undefined 34} 35 36const displayNamesCache = new Map<string, Intl.DisplayNames>() 37 38function getDisplayNames(appLang: string): Intl.DisplayNames { 39 let cached = displayNamesCache.get(appLang) 40 if (!cached) { 41 cached = new Intl.DisplayNames([appLang], { 42 type: 'language', 43 fallback: 'none', 44 languageDisplay: 'standard', 45 }) 46 displayNamesCache.set(appLang, cached) 47 } 48 return cached 49} 50 51function getLocalizedLanguage( 52 langCode: string, 53 appLang: string, 54): string | undefined { 55 try { 56 return getDisplayNames(appLang).of(langCode) || undefined 57 } catch (e) { 58 // ignore RangeError from Intl.DisplayNames APIs 59 if (!(e instanceof RangeError)) { 60 throw e 61 } 62 } 63} 64 65export function languageName(language: Language, appLang: string): string { 66 // if Intl.DisplayNames is unavailable on the target, display the English name 67 if (!Intl.DisplayNames) { 68 return language.name 69 } 70 71 return getLocalizedLanguage(language.code2, appLang) || language.name 72} 73 74export function codeToLanguageName(lang2or3: string, appLang: string): string { 75 const code2 = code3ToCode2(lang2or3) 76 const knownLanguage = LANGUAGES_MAP_CODE2[code2] 77 78 return knownLanguage ? languageName(knownLanguage, appLang) : code2 79} 80 81export function getPostLanguage( 82 post: AppBskyFeedDefs.PostView, 83): string | undefined { 84 let candidates: string[] = [] 85 let postText: string = '' 86 if (hasProp(post.record, 'text') && typeof post.record.text === 'string') { 87 postText = post.record.text 88 } 89 90 if ( 91 AppBskyFeedPost.isRecord(post.record) && 92 hasProp(post.record, 'langs') && 93 Array.isArray(post.record.langs) 94 ) { 95 candidates = post.record.langs 96 } 97 98 // if there's only one declared language, use that 99 if (candidates?.length === 1) { 100 return candidates[0] 101 } 102 103 // no text? can't determine 104 if (postText.trim().length === 0) { 105 return undefined 106 } 107 108 // run the language model 109 let langsProbabilityMap = lande(postText) 110 111 // filter down using declared languages 112 if (candidates?.length) { 113 langsProbabilityMap = langsProbabilityMap.filter( 114 ([lang, _probability]: [string, number]) => { 115 return candidates.includes(code3ToCode2(lang)) 116 }, 117 ) 118 } 119 120 if (langsProbabilityMap[0]) { 121 return code3ToCode2(langsProbabilityMap[0][0]) 122 } 123} 124 125export function isPostInLanguage( 126 post: AppBskyFeedDefs.PostView, 127 targetLangs: string[], 128): boolean { 129 const lang = getPostLanguage(post) 130 if (!lang) { 131 // the post has no text, so we just say "yes" for now 132 return true 133 } 134 return bcp47Match.basicFilter(lang, targetLangs).length > 0 135} 136 137// we cant hook into functions like this, so we will make other translator functions n swap between em 138export function getTranslatorLink( 139 text: string, 140 targetLangCode: string, 141 sourceLanguage?: string, 142): string { 143 return `https://translate.google.com/?sl=${sourceLanguage ?? 'auto'}&tl=${targetLangCode}&text=${encodeURIComponent( 144 text, 145 )}` 146} 147 148export function getTranslatorLinkKagi( 149 text: string, 150 targetLangCode: string, 151 sourceLanguage?: string, 152): string { 153 return `https://translate.kagi.com/?from=${sourceLanguage ?? 'auto'}&to=${targetLangCode}&text=${encodeURIComponent( 154 text, 155 )}` 156} 157 158export function getTranslatorLinkPapago( 159 text: string, 160 targetLangCode: string, 161 sourceLanguage?: string, 162): string { 163 return `https://papago.naver.com/?sk=${sourceLanguage ?? 'auto'}&tk=${targetLangCode}&st=${encodeURIComponent( 164 text, 165 )}` 166} 167 168export function getTranslatorLinkLibreTranslate( 169 text: string, 170 targetLangCode: string, 171 sourceLanguage?: string, 172): string { 173 const instance = 174 persisted.get('libreTranslateInstance') ?? 175 persisted.defaults.libreTranslateInstance! 176 return `${instance}?source=${sourceLanguage ?? 'auto'}&target=${targetLangCode}&q=${encodeURIComponent(text)}` 177} 178 179/** 180 * Returns a valid `appLanguage` value from an arbitrary string. 181 * 182 * Context: post-refactor, we populated some user's `appLanguage` setting with 183 * `postLanguage`, which can be a comma-separated list of values. This breaks 184 * `appLanguage` handling in the app, so we introduced this util to parse out a 185 * valid `appLanguage` from the pre-populated `postLanguage` values. 186 * 187 * The `appLanguage` will continue to be incorrect until the user returns to 188 * language settings and selects a new option, at which point we'll re-save 189 * their choice, which should then be a valid option. Since we don't know when 190 * this will happen, we should leave this here until we feel it's safe to 191 * remove, or we re-migrate their storage. 192 */ 193export function sanitizeAppLanguageSetting(appLanguage: string): AppLanguage { 194 const langs = appLanguage.split(',').filter(Boolean) 195 196 for (const lang of langs) { 197 switch (fixLegacyLanguageCode(lang)) { 198 case 'en': 199 return AppLanguage.en 200 case 'an': 201 return AppLanguage.an 202 case 'ast': 203 return AppLanguage.ast 204 case 'ca': 205 return AppLanguage.ca 206 case 'cy': 207 return AppLanguage.cy 208 case 'da': 209 return AppLanguage.da 210 case 'de': 211 return AppLanguage.de 212 case 'el': 213 return AppLanguage.el 214 case 'en-GB': 215 return AppLanguage.en_GB 216 case 'eo': 217 return AppLanguage.eo 218 case 'es': 219 return AppLanguage.es 220 case 'eu': 221 return AppLanguage.eu 222 case 'fi': 223 return AppLanguage.fi 224 case 'fr': 225 return AppLanguage.fr 226 case 'fy': 227 return AppLanguage.fy 228 case 'ga': 229 return AppLanguage.ga 230 case 'gd': 231 return AppLanguage.gd 232 case 'gl': 233 return AppLanguage.gl 234 case 'hi': 235 return AppLanguage.hi 236 case 'hu': 237 return AppLanguage.hu 238 case 'ia': 239 return AppLanguage.ia 240 case 'id': 241 return AppLanguage.id 242 case 'it': 243 return AppLanguage.it 244 case 'ja': 245 return AppLanguage.ja 246 case 'km': 247 return AppLanguage.km 248 case 'ko': 249 return AppLanguage.ko 250 case 'ne': 251 return AppLanguage.ne 252 case 'nl': 253 return AppLanguage.nl 254 case 'pl': 255 return AppLanguage.pl 256 case 'pt-BR': 257 return AppLanguage.pt_BR 258 case 'pt-PT': 259 return AppLanguage.pt_PT 260 case 'ro': 261 return AppLanguage.ro 262 case 'ru': 263 return AppLanguage.ru 264 case 'sv': 265 return AppLanguage.sv 266 case 'th': 267 return AppLanguage.th 268 case 'tr': 269 return AppLanguage.tr 270 case 'uk': 271 return AppLanguage.uk 272 case 'vi': 273 return AppLanguage.vi 274 case 'zh-Hans-CN': 275 return AppLanguage.zh_CN 276 case 'zh-Hant-HK': 277 return AppLanguage.zh_HK 278 case 'zh-Hant-TW': 279 return AppLanguage.zh_TW 280 default: 281 continue 282 } 283 } 284 return AppLanguage.en 285} 286 287/** 288 * Handles legacy migration for Java devices. 289 * 290 * {@link https://github.com/bluesky-social/social-app/pull/4461} 291 * {@link https://xml.coverpages.org/iso639a.html} 292 */ 293export function fixLegacyLanguageCode(code: string | null): string | null { 294 if (code === 'in') { 295 // indonesian 296 return 'id' 297 } 298 if (code === 'iw') { 299 // hebrew 300 return 'he' 301 } 302 if (code === 'ji') { 303 // yiddish 304 return 'yi' 305 } 306 return code 307} 308 309/** 310 * Find the first language supported by our translation infra. Values should be 311 * in order of preference, and match the values of {@link AppLanguage}. 312 * 313 * If no match, returns `en`. 314 */ 315export function findSupportedAppLanguage(languageTags: (string | undefined)[]) { 316 const supported = new Set(Object.values(AppLanguage)) 317 for (const tag of languageTags) { 318 if (!tag) continue 319 if (supported.has(tag as AppLanguage)) { 320 return tag 321 } 322 } 323 return AppLanguage.en 324} 325 326/** 327 * Gets region name for a given country code and language. 328 * 329 * Falls back to English if unavailable/error, and if that fails, returns the country code. 330 * 331 * Intl.DisplayNames is widely available + has been polyfilled on native 332 */ 333export function regionName(countryCode: string, appLang: string): string { 334 const translatedName = getLocalizedRegionName(countryCode, appLang) 335 336 if (translatedName) { 337 return translatedName 338 } 339 340 // Fallback: get English name. Needed for i.e. Esperanto 341 const englishName = getLocalizedRegionName(countryCode, 'en') 342 if (englishName) { 343 return englishName 344 } 345 346 // Final fallback: return country code 347 return countryCode 348} 349 350const regionNamesCache = new Map<string, Intl.DisplayNames>() 351 352function getRegionNames(appLang: string): Intl.DisplayNames { 353 let cached = regionNamesCache.get(appLang) 354 if (!cached) { 355 cached = new Intl.DisplayNames([appLang], { 356 type: 'region', 357 fallback: 'none', 358 }) 359 regionNamesCache.set(appLang, cached) 360 } 361 return cached 362} 363 364function getLocalizedRegionName( 365 countryCode: string, 366 appLang: string, 367): string | undefined { 368 try { 369 return getRegionNames(appLang).of(countryCode) 370 } catch (err) { 371 console.warn('Error getting localized region name:', err) 372 return undefined 373 } 374}