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