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