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 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}