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