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