Bluesky app fork with some witchin' additions 💫

Refine UX for on-device translation for posts (#9987)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>

authored by

DS Boyce
Samuel Newman
Eric Bailey
and committed by
GitHub
afbade6f 1782a651

+1013 -1638
+1 -1
package.json
··· 85 85 "@braintree/sanitize-url": "^6.0.2", 86 86 "@bsky.app/alf": "^0.1.7", 87 87 "@bsky.app/expo-image-crop-tool": "^0.5.0", 88 - "@bsky.app/expo-translate-text": "^0.2.4", 88 + "@bsky.app/expo-translate-text": "^0.2.7", 89 89 "@bsky.app/react-native-mmkv": "2.12.5", 90 90 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", 91 91 "@emoji-mart/data": "^1.2.1",
+6 -3
src/App.native.tsx
··· 19 19 import {QueryProvider} from '#/lib/react-query' 20 20 import {s} from '#/lib/styles' 21 21 import {ThemeProvider} from '#/lib/ThemeContext' 22 + import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' 22 23 import I18nProvider from '#/locale/i18nProvider' 23 24 import {logger} from '#/logger' 24 25 import {Provider as A11yProvider} from '#/state/a11y' ··· 179 180 style={s.h100pct}> 180 181 <GlobalGestureEventsProvider> 181 182 <IntentDialogProvider> 182 - <TestCtrls /> 183 - <Shell /> 184 - <ToastOutlet /> 183 + <TranslateOnDeviceProvider> 184 + <TestCtrls /> 185 + <Shell /> 186 + <ToastOutlet /> 187 + </TranslateOnDeviceProvider> 185 188 </IntentDialogProvider> 186 189 </GlobalGestureEventsProvider> 187 190 </GestureHandlerRootView>
+5 -2
src/App.web.tsx
··· 10 10 11 11 import {QueryProvider} from '#/lib/react-query' 12 12 import {ThemeProvider} from '#/lib/ThemeContext' 13 + import {Provider as TranslateOnDeviceProvider} from '#/lib/translation' 13 14 import I18nProvider from '#/locale/i18nProvider' 14 15 import {logger} from '#/logger' 15 16 import {Provider as A11yProvider} from '#/state/a11y' ··· 154 155 <EmailVerificationProvider> 155 156 <HideBottomBarBorderProvider> 156 157 <IntentDialogProvider> 157 - <Shell /> 158 - <ToastOutlet /> 158 + <TranslateOnDeviceProvider> 159 + <Shell /> 160 + <ToastOutlet /> 161 + </TranslateOnDeviceProvider> 159 162 </IntentDialogProvider> 160 163 </HideBottomBarBorderProvider> 161 164 </EmailVerificationProvider>
+313 -67
src/components/Post/Translated/index.tsx
··· 1 - import {useMemo} from 'react' 1 + import {useCallback, useMemo} from 'react' 2 2 import {Platform, View} from 'react-native' 3 - import {msg} from '@lingui/core/macro' 4 - import {useLingui} from '@lingui/react' 5 - import {Trans} from '@lingui/react/macro' 3 + import {type AppBskyFeedDefs} from '@atproto/api' 4 + import {Trans, useLingui} from '@lingui/react/macro' 6 5 6 + import {HITSLOP_30} from '#/lib/constants' 7 + import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' 8 + import {guessLanguage, useTranslate} from '#/lib/translation' 9 + import {type TranslationFunction} from '#/lib/translation' 7 10 import {codeToLanguageName, languageName} from '#/locale/helpers' 8 11 import {LANGUAGES} from '#/locale/languages' 9 12 import {useLanguagePrefs} from '#/state/preferences' 10 - import {atoms as a, useTheme} from '#/alf' 13 + import {atoms as a, native, useTheme, web} from '#/alf' 14 + import {Button} from '#/components/Button' 15 + import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon} from '#/components/icons/Arrow' 16 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 17 + import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' 18 + import {createStaticClick, Link} from '#/components/Link' 11 19 import {Loader} from '#/components/Loader' 12 20 import * as Select from '#/components/Select' 13 21 import {Text} from '#/components/Typography' 14 22 import {useAnalytics} from '#/analytics' 15 - import {useTranslateOnDevice} from '#/translation' 23 + import {IS_WEB} from '#/env' 16 24 17 25 export function TranslatedPost({ 26 + hideTranslateLink = false, 27 + post, 18 28 postText, 19 - hideLoading = false, 20 29 }: { 30 + hideTranslateLink?: boolean 31 + post: AppBskyFeedDefs.PostView 21 32 postText: string 22 - hideLoading: boolean 23 33 }) { 24 - const {translationState} = useTranslateOnDevice() 34 + const langPrefs = useLanguagePrefs() 35 + const {clearTranslation, translate, translationState} = useTranslate({ 36 + key: post.uri, 37 + }) 25 38 26 - if (translationState.status === 'loading' && !hideLoading) { 27 - return <TranslationLoading /> 28 - } 39 + const postLanguage = useMemo(() => guessLanguage(postText), [postText]) 40 + const needsTranslation = postLanguage !== langPrefs.primaryLanguage 29 41 30 - if (translationState.status === 'success') { 31 - return ( 32 - <TranslationResult 33 - postText={postText} 34 - sourceLanguage={translationState.sourceLanguage} 35 - translatedText={translationState.translatedText} 36 - /> 37 - ) 42 + switch (translationState.status) { 43 + case 'loading': 44 + return <TranslationLoading /> 45 + case 'success': 46 + return ( 47 + <TranslationResult 48 + clearTranslation={clearTranslation} 49 + translate={translate} 50 + postText={postText} 51 + sourceLanguage={ 52 + translationState.sourceLanguage ?? postLanguage ?? null // Fallback primarily for iOS 53 + } 54 + translatedText={translationState.translatedText} 55 + /> 56 + ) 57 + case 'error': 58 + return ( 59 + <TranslationError 60 + clearTranslation={clearTranslation} 61 + message={translationState.message} 62 + postText={postText} 63 + primaryLanguage={langPrefs.primaryLanguage} 64 + /> 65 + ) 66 + default: 67 + return ( 68 + !hideTranslateLink && 69 + needsTranslation && ( 70 + <TranslationLink 71 + postText={postText} 72 + primaryLanguage={langPrefs.primaryLanguage} 73 + sourceLanguage={postLanguage} 74 + translate={translate} 75 + /> 76 + ) 77 + ) 38 78 } 39 - 40 - return null 41 79 } 42 80 43 81 function TranslationLoading() { 44 82 const t = useTheme() 45 83 46 84 return ( 47 - <View style={[a.flex_row, a.align_center, a.gap_sm, a.py_xs]}> 48 - <Loader size="sm" /> 49 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 50 - <Trans>Translating…</Trans> 51 - </Text> 85 + <View style={[a.gap_md, a.pt_md, a.align_start]}> 86 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 87 + <Loader size="xs" /> 88 + <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 89 + <Trans>Translating…</Trans> 90 + </Text> 91 + </View> 92 + </View> 93 + ) 94 + } 95 + 96 + function TranslationLink({ 97 + postText, 98 + primaryLanguage, 99 + sourceLanguage, 100 + translate, 101 + }: { 102 + postText: string 103 + primaryLanguage: string 104 + sourceLanguage: string | null 105 + translate: TranslationFunction 106 + }) { 107 + const t = useTheme() 108 + const {t: l} = useLingui() 109 + const ax = useAnalytics() 110 + 111 + const handleTranslate = useCallback(() => { 112 + void translate({ 113 + text: postText, 114 + targetLangCode: primaryLanguage, 115 + }) 116 + 117 + ax.metric('translate', { 118 + sourceLanguages: sourceLanguage ? [sourceLanguage] : [], 119 + targetLanguage: primaryLanguage, 120 + textLength: postText.length, 121 + }) 122 + }, [ax, postText, primaryLanguage, translate, sourceLanguage]) 123 + 124 + return ( 125 + <View 126 + style={[ 127 + a.gap_md, 128 + a.pt_md, 129 + a.align_start, 130 + a.flex_row, 131 + a.align_center, 132 + a.gap_xs, 133 + ]}> 134 + <Link 135 + role={IS_WEB ? 'link' : 'button'} 136 + {...createStaticClick(() => { 137 + handleTranslate() 138 + })} 139 + label={l`Translate`} 140 + hoverStyle={[ 141 + native({opacity: 0.5}), 142 + web([a.underline, {textDecorationColor: t.palette.primary_500}]), 143 + ]} 144 + hitSlop={HITSLOP_30}> 145 + <Text style={[a.text_sm, {color: t.palette.primary_500}]}> 146 + <Trans>Translate</Trans> 147 + </Text> 148 + </Link> 149 + </View> 150 + ) 151 + } 152 + 153 + function TranslationError({ 154 + clearTranslation, 155 + message, 156 + postText, 157 + primaryLanguage, 158 + }: { 159 + clearTranslation: () => void 160 + message: string 161 + postText: string 162 + primaryLanguage: string 163 + }) { 164 + const t = useTheme() 165 + const {t: l} = useLingui() 166 + const translate = useGoogleTranslate() 167 + 168 + const handleFallback = () => { 169 + void translate(postText, primaryLanguage) 170 + } 171 + 172 + return ( 173 + <View 174 + style={[ 175 + a.px_lg, 176 + a.pt_sm, 177 + a.pb_md, 178 + a.mt_sm, 179 + a.border, 180 + a.rounded_lg, 181 + t.atoms.border_contrast_high, 182 + ]}> 183 + <View style={[a.flex_row, a.align_center, a.justify_between]}> 184 + <View style={[a.flex_row, a.align_center, a.mb_sm, a.gap_xs]}> 185 + <WarningIcon size="sm" fill={t.atoms.text_contrast_medium.color} /> 186 + <Text style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 187 + {message} 188 + </Text> 189 + </View> 190 + <View style={[a.flex_row, a.align_center, a.mb_xs]}> 191 + <Button 192 + label={l`Hide translation`} 193 + hitSlop={HITSLOP_30} 194 + hoverStyle={{opacity: 0.5}} 195 + onPress={clearTranslation}> 196 + <XIcon size="sm" fill={t.atoms.text_contrast_medium.color} /> 197 + </Button> 198 + </View> 199 + </View> 200 + <View style={[a.flex_row, a.align_center]}> 201 + <Link 202 + {...createStaticClick(() => { 203 + handleFallback() 204 + })} 205 + label={l`Try Google Translate`} 206 + hoverStyle={[ 207 + native({opacity: 0.5}), 208 + web([a.underline, {textDecorationColor: t.palette.primary_500}]), 209 + ]} 210 + hitSlop={HITSLOP_30}> 211 + <Text 212 + style={[a.text_xs, a.font_medium, {color: t.palette.primary_500}]}> 213 + <Trans>Try Google Translate</Trans> 214 + </Text> 215 + </Link> 216 + </View> 52 217 </View> 53 218 ) 54 219 } 55 220 56 221 function TranslationResult({ 222 + clearTranslation, 223 + translate, 57 224 postText, 58 225 sourceLanguage, 59 226 translatedText, 60 227 }: { 228 + clearTranslation: () => void 229 + translate: TranslationFunction 61 230 postText: string 62 231 sourceLanguage: string | null 63 232 translatedText: string 64 233 }) { 65 234 const t = useTheme() 66 - const {i18n} = useLingui() 235 + const langPrefs = useLanguagePrefs() 236 + const {i18n, t: l} = useLingui() 67 237 68 238 const langName = sourceLanguage 69 239 ? codeToLanguageName(sourceLanguage, i18n.locale) 70 240 : undefined 71 241 72 242 return ( 73 - <View style={[a.py_xs, a.gap_xs, a.mt_sm]}> 74 - <Text style={[a.text_xs, t.atoms.text_contrast_medium]}> 75 - {langName ? ( 76 - <Trans>Translated from {langName}</Trans> 77 - ) : ( 78 - <Trans>Translated</Trans> 79 - )} 80 - {sourceLanguage != null && ( 81 - <> 82 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 83 - {' '} 84 - &middot; 85 - </Text>{' '} 86 - <TranslationLanguageSelect 87 - sourceLanguage={sourceLanguage} 88 - postText={postText} 89 - /> 90 - </> 91 - )} 92 - </Text> 93 - <Text emoji selectable style={[a.text_md, a.leading_snug]}> 94 - {translatedText} 95 - </Text> 243 + <View> 244 + <View 245 + style={[ 246 + a.px_lg, 247 + a.pt_sm, 248 + a.pb_md, 249 + a.mt_sm, 250 + a.border, 251 + a.rounded_lg, 252 + t.atoms.border_contrast_high, 253 + ]}> 254 + <View style={[a.flex_row, a.align_center, a.mb_xs]}> 255 + {langName ? ( 256 + <View style={[a.flex_row, a.align_center]}> 257 + <Text 258 + style={[ 259 + a.text_xs, 260 + a.font_medium, 261 + t.atoms.text_contrast_medium, 262 + ]}> 263 + {langName}{' '} 264 + </Text> 265 + <View style={[a.mt_2xs]}> 266 + <ArrowRightIcon 267 + size="xs" 268 + fill={t.atoms.text_contrast_medium.color} 269 + /> 270 + </View> 271 + <Text 272 + style={[ 273 + a.text_xs, 274 + a.font_medium, 275 + t.atoms.text_contrast_medium, 276 + ]}> 277 + {' '} 278 + {codeToLanguageName( 279 + langPrefs.primaryLanguage, 280 + langPrefs.appLanguage, 281 + )} 282 + </Text> 283 + </View> 284 + ) : ( 285 + <Text 286 + style={[ 287 + a.text_xs, 288 + a.font_medium, 289 + t.atoms.text_contrast_medium, 290 + a.mb_xs, 291 + ]}> 292 + <Trans>Translated</Trans> 293 + </Text> 294 + )} 295 + {sourceLanguage != null && ( 296 + <> 297 + <Text 298 + style={[ 299 + a.text_xs, 300 + a.font_medium, 301 + t.atoms.text_contrast_medium, 302 + ]}> 303 + {' '} 304 + &middot;{' '} 305 + </Text> 306 + <TranslationLanguageSelect 307 + sourceLanguage={sourceLanguage} 308 + translate={translate} 309 + postText={postText} 310 + /> 311 + </> 312 + )} 313 + </View> 314 + <Text emoji selectable style={[a.text_md, a.leading_snug]}> 315 + {translatedText} 316 + </Text> 317 + <Button 318 + label={l`Hide translation`} 319 + hitSlop={HITSLOP_30} 320 + hoverStyle={native({opacity: 0.5})} 321 + style={[a.absolute, a.z_10, {top: 12, right: 14}]} 322 + onPress={clearTranslation}> 323 + <XIcon size="sm" fill={t.atoms.text_contrast_medium.color} /> 324 + </Button> 325 + </View> 96 326 </View> 97 327 ) 98 328 } 99 329 100 330 function TranslationLanguageSelect({ 331 + translate, 101 332 postText, 102 333 sourceLanguage, 103 334 }: { 335 + translate: TranslationFunction 104 336 postText: string 105 337 sourceLanguage: string 106 338 }) { 339 + const t = useTheme() 107 340 const ax = useAnalytics() 108 - const {_} = useLingui() 341 + const {t: l} = useLingui() 109 342 const langPrefs = useLanguagePrefs() 110 - const {translate} = useTranslateOnDevice() 111 343 112 344 const items = useMemo( 113 345 () => ··· 116 348 !langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant 117 349 index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2) 118 350 ) 119 - .sort( 120 - (a, b) => 121 - languageName(a, langPrefs.appLanguage).localeCompare( 122 - languageName(b, langPrefs.appLanguage), 123 - langPrefs.appLanguage, 124 - ), // Localized sort 125 - ) 351 + .sort((a, b) => { 352 + // Prioritize sourceLanguage at the top 353 + if (a.code2 === sourceLanguage) return -1 354 + if (b.code2 === sourceLanguage) return 1 355 + // Localized sort 356 + return languageName(a, langPrefs.appLanguage).localeCompare( 357 + languageName(b, langPrefs.appLanguage), 358 + langPrefs.appLanguage, 359 + ) 360 + }) 126 361 .map(l => ({ 127 362 label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name 128 363 value: l.code2, 129 364 })), 130 - [langPrefs], 365 + [langPrefs, sourceLanguage], 131 366 ) 132 367 133 368 const handleChangeTranslationLanguage = (sourceLangCode: string) => { ··· 136 371 sourceLanguage: sourceLangCode, 137 372 targetLanguage: langPrefs.primaryLanguage, 138 373 }) 139 - void translate(postText, langPrefs.primaryLanguage, sourceLangCode) 374 + void translate({ 375 + text: postText, 376 + targetLangCode: langPrefs.primaryLanguage, 377 + sourceLangCode, 378 + }) 140 379 } 141 380 142 381 return ( 143 382 <Select.Root 144 383 value={sourceLanguage} 145 384 onValueChange={handleChangeTranslationLanguage}> 146 - <Select.Trigger hitSlop={10} label={_(msg`Change source language`)}> 385 + <Select.Trigger label={l`Change the source language`}> 147 386 {({props}) => { 148 387 return ( 149 - <Text {...props} style={[a.text_xs]}> 150 - <Trans>Change</Trans> 151 - </Text> 388 + <Button 389 + label={props.accessibilityLabel} 390 + {...props} 391 + hitSlop={HITSLOP_30} 392 + hoverStyle={native({opacity: 0.5})}> 393 + <Text 394 + style={[a.text_xs, a.font_medium, t.atoms.text_contrast_high]}> 395 + <Trans>Change</Trans> 396 + </Text> 397 + </Button> 152 398 ) 153 399 }} 154 400 </Select.Trigger> 155 401 <Select.Content 156 - label={_(msg`Select the source language`)} 402 + label={l`Select the source language`} 157 403 renderItem={({label, value}) => ( 158 404 <Select.Item value={value} label={label}> 159 405 <Select.ItemIndicator />
+121 -133
src/components/PostControls/PostMenu/PostMenuItems.tsx
··· 13 13 AtUri, 14 14 type RichText as RichTextAPI, 15 15 } from '@atproto/api' 16 - import {msg, plural} from '@lingui/core/macro' 17 - import {useLingui} from '@lingui/react' 16 + import {plural} from '@lingui/core/macro' 17 + import {useLingui} from '@lingui/react/macro' 18 18 import {useNavigation} from '@react-navigation/native' 19 19 20 20 import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' 21 21 import {useOpenLink} from '#/lib/hooks/useOpenLink' 22 - import {useTranslate} from '#/lib/hooks/useTranslate' 23 22 import {getCurrentRoute} from '#/lib/routes/helpers' 24 23 import {makeProfileLink} from '#/lib/routes/links' 25 24 import { ··· 28 27 } from '#/lib/routes/types' 29 28 import {richTextToString} from '#/lib/strings/rich-text-helpers' 30 29 import {toShareUrl} from '#/lib/strings/url-helpers' 30 + import {useTranslate} from '#/lib/translation' 31 31 import {logger} from '#/logger' 32 32 import {type Shadow} from '#/state/cache/post-shadow' 33 33 import {useProfileShadow} from '#/state/cache/profile-shadow' ··· 106 106 threadgateRecord, 107 107 onShowLess, 108 108 logContext, 109 + forceGoogleTranslate, 109 110 }: { 110 111 testID: string 111 112 post: Shadow<AppBskyFeedDefs.PostView> ··· 120 121 threadgateRecord?: AppBskyFeedThreadgate.Record 121 122 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 122 123 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 124 + forceGoogleTranslate: boolean 123 125 }): React.ReactNode => { 124 126 const {hasSession, currentAccount} = useSession() 125 - const {_} = useLingui() 127 + const {t: l} = useLingui() 126 128 const ax = useAnalytics() 127 129 const langPrefs = useLanguagePrefs() 128 130 const {mutateAsync: deletePostMutate} = usePostDeleteMutation() ··· 133 135 const {hidePost} = useHiddenPostsApi() 134 136 const feedFeedback = useFeedFeedbackContext() 135 137 const openLink = useOpenLink() 136 - const translate = useTranslate() 138 + const {clearTranslation, translate, translationState} = useTranslate({ 139 + key: post.uri, 140 + forceGoogleTranslate, 141 + }) 137 142 const navigation = useNavigation<NavigationProp>() 138 143 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 139 144 const blockPromptControl = useDialogControl() ··· 191 196 const onDeletePost = () => { 192 197 deletePostMutate({uri: postUri}).then( 193 198 () => { 194 - Toast.show(_(msg({message: 'Post deleted', context: 'toast'}))) 199 + Toast.show(l({message: 'Post deleted', context: 'toast'})) 195 200 196 201 const route = getCurrentRoute(navigation.getState()) 197 202 if (route.name === 'PostThread') { ··· 211 216 }, 212 217 e => { 213 218 logger.error('Failed to delete post', {message: e}) 214 - Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') 219 + Toast.show(l`Failed to delete post, please try again`, 'xmark') 215 220 }, 216 221 ) 217 222 } ··· 226 231 logContext, 227 232 feedDescriptor: feedFeedback.feedDescriptor, 228 233 }) 229 - Toast.show(_(msg`You will now receive notifications for this thread`)) 234 + Toast.show(l`You will now receive notifications for this thread`) 230 235 } else { 231 236 void muteThread() 232 237 ax.metric('post:mute', { ··· 235 240 logContext, 236 241 feedDescriptor: feedFeedback.feedDescriptor, 237 242 }) 238 - Toast.show( 239 - _(msg`You will no longer receive notifications for this thread`), 240 - ) 243 + Toast.show(l`You will no longer receive notifications for this thread`) 241 244 } 242 245 } catch (err) { 243 246 const e = err as Error 244 247 if (e?.name !== 'AbortError') { 245 248 logger.error('Failed to toggle thread mute', {message: e}) 246 - Toast.show( 247 - _(msg`Failed to toggle thread mute, please try again`), 248 - 'xmark', 249 - ) 249 + Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark') 250 250 } 251 251 } 252 252 } ··· 255 255 const str = richTextToString(richText, true) 256 256 257 257 void Clipboard.setStringAsync(str) 258 - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 258 + Toast.show(l`Copied to clipboard`, 'clipboard-check') 259 259 } 260 260 261 261 const onPressTranslate = () => { 262 - void translate(record.text, langPrefs.primaryLanguage) 262 + void translate({ 263 + text: record.text, 264 + targetLangCode: langPrefs.primaryLanguage, 265 + }) 263 266 264 267 if ( 265 268 bsky.dangerousIsType<AppBskyFeedPost.Record>( ··· 297 300 logContext, 298 301 feedDescriptor: feedFeedback.feedDescriptor, 299 302 }) 300 - Toast.show( 301 - _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 302 - ) 303 + Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'})) 303 304 } 304 305 305 306 const onPressShowLess = () => { ··· 322 323 }) 323 324 } else { 324 325 Toast.show( 325 - _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), 326 + l({message: 'Feedback sent to feed operator', context: 'toast'}), 326 327 ) 327 328 } 328 329 } ··· 341 342 }) 342 343 Toast.show( 343 344 isDetach 344 - ? _(msg`Quote post was successfully detached`) 345 - : _(msg`Quote post was re-attached`), 345 + ? l`Quote post was successfully detached` 346 + : l`Quote post was re-attached`, 346 347 ) 347 348 } catch (err) { 348 349 const e = err as Error 349 350 Toast.show( 350 - _(msg({message: 'Updating quote attachment failed', context: 'toast'})), 351 + l({message: 'Updating quote attachment failed', context: 'toast'}), 351 352 ) 352 353 logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) 353 354 } ··· 379 380 380 381 Toast.show( 381 382 isHide 382 - ? _(msg`Reply was successfully hidden`) 383 - : _(msg({message: 'Reply visibility updated', context: 'toast'})), 383 + ? l`Reply was successfully hidden` 384 + : l({message: 'Reply visibility updated', context: 'toast'}), 384 385 ) 385 386 } catch (err) { 386 387 const e = err as Error 387 388 if (e instanceof MaxHiddenRepliesError) { 388 389 Toast.show( 389 - _( 390 - plural(MAX_HIDDEN_REPLIES, { 391 - other: 'You can hide a maximum of # replies.', 392 - }), 393 - ), 390 + plural(MAX_HIDDEN_REPLIES, { 391 + other: 'You can hide a maximum of # replies.', 392 + }), 394 393 ) 395 394 } else if (e instanceof InvalidInteractionSettingsError) { 396 395 Toast.show( 397 - _(msg({message: 'Invalid interaction settings.', context: 'toast'})), 396 + l({message: 'Invalid interaction settings.', context: 'toast'}), 398 397 ) 399 398 } else { 400 399 Toast.show( 401 - _( 402 - msg({ 403 - message: 'Updating reply visibility failed', 404 - context: 'toast', 405 - }), 406 - ), 400 + l({ 401 + message: 'Updating reply visibility failed', 402 + context: 'toast', 403 + }), 407 404 ) 408 405 logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) 409 406 } ··· 422 419 const onBlockAuthor = async () => { 423 420 try { 424 421 await queueBlock() 425 - Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) 422 + Toast.show(l({message: 'Account blocked', context: 'toast'})) 426 423 } catch (err) { 427 424 const e = err as Error 428 425 if (e?.name !== 'AbortError') { 429 426 logger.error('Failed to block account', {message: e}) 430 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 427 + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') 431 428 } 432 429 } 433 430 } ··· 436 433 if (postAuthor.viewer?.muted) { 437 434 try { 438 435 await queueUnmute() 439 - Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) 436 + Toast.show(l({message: 'Account unmuted', context: 'toast'})) 440 437 } catch (err) { 441 438 const e = err as Error 442 439 if (e?.name !== 'AbortError') { 443 440 logger.error('Failed to unmute account', {message: e}) 444 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 441 + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') 445 442 } 446 443 } 447 444 } else { 448 445 try { 449 446 await queueMute() 450 - Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) 447 + Toast.show(l({message: 'Account muted', context: 'toast'})) 451 448 } catch (err) { 452 449 const e = err as Error 453 450 if (e?.name !== 'AbortError') { 454 451 logger.error('Failed to mute account', {message: e}) 455 - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') 452 + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') 456 453 } 457 454 } 458 455 } ··· 467 464 468 465 const onSignIn = () => requireSignIn(() => {}) 469 466 467 + const onPressHideTranslation = () => clearTranslation() 468 + 470 469 const isDiscoverDebugUser = 471 470 IS_INTERNAL || 472 471 DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || ··· 481 480 <Menu.Item 482 481 testID="pinPostBtn" 483 482 label={ 484 - isPinned 485 - ? _(msg`Unpin from profile`) 486 - : _(msg`Pin to your profile`) 483 + isPinned ? l`Unpin from profile` : l`Pin to your profile` 487 484 } 488 485 disabled={isPinPending} 489 486 onPress={onPressPin}> 490 487 <Menu.ItemText> 491 - {isPinned 492 - ? _(msg`Unpin from profile`) 493 - : _(msg`Pin to your profile`)} 488 + {isPinned ? l`Unpin from profile` : l`Pin to your profile`} 494 489 </Menu.ItemText> 495 490 <Menu.ItemIcon 496 491 icon={isPinPending ? Loader : PinIcon} ··· 505 500 <Menu.Group> 506 501 {!hideInPWI || hasSession ? ( 507 502 <> 508 - <Menu.Item 509 - testID="postDropdownTranslateBtn" 510 - label={_(msg`Translate`)} 511 - onPress={onPressTranslate}> 512 - <Menu.ItemText>{_(msg`Translate`)}</Menu.ItemText> 513 - <Menu.ItemIcon icon={Translate} position="right" /> 514 - </Menu.Item> 503 + {translationState.status === 'loading' ? ( 504 + <Menu.Item 505 + testID="postDropdownTranslateBtn" 506 + label={l`Translating…`} 507 + onPress={() => {}}> 508 + <Menu.ItemText>{l`Translating…`}</Menu.ItemText> 509 + <Menu.ItemIcon icon={Translate} position="right" /> 510 + </Menu.Item> 511 + ) : translationState.status === 'success' ? ( 512 + <Menu.Item 513 + testID="postDropdownTranslateBtn" 514 + label={l`Hide translation`} 515 + onPress={onPressHideTranslation}> 516 + <Menu.ItemText>{l`Hide translation`}</Menu.ItemText> 517 + <Menu.ItemIcon icon={Translate} position="right" /> 518 + </Menu.Item> 519 + ) : ( 520 + <Menu.Item 521 + testID="postDropdownTranslateBtn" 522 + label={l`Translate`} 523 + onPress={onPressTranslate}> 524 + <Menu.ItemText>{l`Translate`}</Menu.ItemText> 525 + <Menu.ItemIcon icon={Translate} position="right" /> 526 + </Menu.Item> 527 + )} 515 528 516 529 <Menu.Item 517 530 testID="postDropdownCopyTextBtn" 518 - label={_(msg`Copy post text`)} 531 + label={l`Copy post text`} 519 532 onPress={onCopyPostText}> 520 - <Menu.ItemText>{_(msg`Copy post text`)}</Menu.ItemText> 533 + <Menu.ItemText>{l`Copy post text`}</Menu.ItemText> 521 534 <Menu.ItemIcon icon={ClipboardIcon} position="right" /> 522 535 </Menu.Item> 523 536 </> 524 537 ) : ( 525 538 <Menu.Item 526 539 testID="postDropdownSignInBtn" 527 - label={_(msg`Sign in to view post`)} 540 + label={l`Sign in to view post`} 528 541 onPress={onSignIn}> 529 - <Menu.ItemText>{_(msg`Sign in to view post`)}</Menu.ItemText> 542 + <Menu.ItemText>{l`Sign in to view post`}</Menu.ItemText> 530 543 <Menu.ItemIcon icon={Eye} position="right" /> 531 544 </Menu.Item> 532 545 )} ··· 538 551 <Menu.Group> 539 552 <Menu.Item 540 553 testID="postDropdownShowMoreBtn" 541 - label={_(msg`Show more like this`)} 554 + label={l`Show more like this`} 542 555 onPress={onPressShowMore}> 543 - <Menu.ItemText>{_(msg`Show more like this`)}</Menu.ItemText> 556 + <Menu.ItemText>{l`Show more like this`}</Menu.ItemText> 544 557 <Menu.ItemIcon icon={EmojiSmile} position="right" /> 545 558 </Menu.Item> 546 559 547 560 <Menu.Item 548 561 testID="postDropdownShowLessBtn" 549 - label={_(msg`Show less like this`)} 562 + label={l`Show less like this`} 550 563 onPress={onPressShowLess}> 551 - <Menu.ItemText>{_(msg`Show less like this`)}</Menu.ItemText> 564 + <Menu.ItemText>{l`Show less like this`}</Menu.ItemText> 552 565 <Menu.ItemIcon icon={EmojiSad} position="right" /> 553 566 </Menu.Item> 554 567 </Menu.Group> ··· 560 573 <Menu.Divider /> 561 574 <Menu.Item 562 575 testID="postDropdownReportMisclassificationBtn" 563 - label={_(msg`Assign topic for algo`)} 576 + label={l`Assign topic for algo`} 564 577 onPress={onReportMisclassification}> 565 - <Menu.ItemText>{_(msg`Assign topic for algo`)}</Menu.ItemText> 578 + <Menu.ItemText>{l`Assign topic for algo`}</Menu.ItemText> 566 579 <Menu.ItemIcon icon={AtomIcon} position="right" /> 567 580 </Menu.Item> 568 581 </> ··· 574 587 <Menu.Group> 575 588 <Menu.Item 576 589 testID="postDropdownMuteThreadBtn" 577 - label={ 578 - isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`) 579 - } 590 + label={isThreadMuted ? l`Unmute thread` : l`Mute thread`} 580 591 onPress={onToggleThreadMute}> 581 592 <Menu.ItemText> 582 - {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} 593 + {isThreadMuted ? l`Unmute thread` : l`Mute thread`} 583 594 </Menu.ItemText> 584 595 <Menu.ItemIcon 585 596 icon={isThreadMuted ? Unmute : Mute} ··· 589 600 590 601 <Menu.Item 591 602 testID="postDropdownMuteWordsBtn" 592 - label={_(msg`Mute words & tags`)} 603 + label={l`Mute words & tags`} 593 604 onPress={() => mutedWordsDialogControl.open()}> 594 - <Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText> 605 + <Menu.ItemText>{l`Mute words & tags`}</Menu.ItemText> 595 606 <Menu.ItemIcon icon={Filter} position="right" /> 596 607 </Menu.Item> 597 608 </Menu.Group> ··· 606 617 {canHidePostForMe && ( 607 618 <Menu.Item 608 619 testID="postDropdownHideBtn" 609 - label={ 610 - isReply 611 - ? _(msg`Hide reply for me`) 612 - : _(msg`Hide post for me`) 613 - } 620 + label={isReply ? l`Hide reply for me` : l`Hide post for me`} 614 621 onPress={() => hidePromptControl.open()}> 615 622 <Menu.ItemText> 616 - {isReply 617 - ? _(msg`Hide reply for me`) 618 - : _(msg`Hide post for me`)} 623 + {isReply ? l`Hide reply for me` : l`Hide post for me`} 619 624 </Menu.ItemText> 620 625 <Menu.ItemIcon icon={EyeSlash} position="right" /> 621 626 </Menu.Item> ··· 625 630 testID="postDropdownHideBtn" 626 631 label={ 627 632 isReplyHiddenByThreadgate 628 - ? _(msg`Show reply for everyone`) 629 - : _(msg`Hide reply for everyone`) 633 + ? l`Show reply for everyone` 634 + : l`Hide reply for everyone` 630 635 } 631 636 onPress={ 632 637 isReplyHiddenByThreadgate ··· 635 640 }> 636 641 <Menu.ItemText> 637 642 {isReplyHiddenByThreadgate 638 - ? _(msg`Show reply for everyone`) 639 - : _(msg`Hide reply for everyone`)} 643 + ? l`Show reply for everyone` 644 + : l`Hide reply for everyone`} 640 645 </Menu.ItemText> 641 646 <Menu.ItemIcon 642 647 icon={isReplyHiddenByThreadgate ? Eye : EyeSlash} ··· 651 656 testID="postDropdownHideBtn" 652 657 label={ 653 658 quoteEmbed.isDetached 654 - ? _(msg`Re-attach quote`) 655 - : _(msg`Detach quote`) 659 + ? l`Re-attach quote` 660 + : l`Detach quote` 656 661 } 657 662 onPress={ 658 663 quoteEmbed.isDetached ··· 661 666 }> 662 667 <Menu.ItemText> 663 668 {quoteEmbed.isDetached 664 - ? _(msg`Re-attach quote`) 665 - : _(msg`Detach quote`)} 669 + ? l`Re-attach quote` 670 + : l`Detach quote`} 666 671 </Menu.ItemText> 667 672 <Menu.ItemIcon 668 673 icon={ ··· 690 695 testID="postDropdownMuteBtn" 691 696 label={ 692 697 postAuthor.viewer?.muted 693 - ? _(msg`Unmute account`) 694 - : _(msg`Mute account`) 698 + ? l`Unmute account` 699 + : l`Mute account` 695 700 } 696 701 onPress={() => void onMuteAuthor()}> 697 702 <Menu.ItemText> 698 703 {postAuthor.viewer?.muted 699 - ? _(msg`Unmute account`) 700 - : _(msg`Mute account`)} 704 + ? l`Unmute account` 705 + : l`Mute account`} 701 706 </Menu.ItemText> 702 707 <Menu.ItemIcon 703 708 icon={postAuthor.viewer?.muted ? UnmuteIcon : MuteIcon} ··· 708 713 {!postAuthor.viewer?.blocking && ( 709 714 <Menu.Item 710 715 testID="postDropdownBlockBtn" 711 - label={_(msg`Block account`)} 716 + label={l`Block account`} 712 717 onPress={() => blockPromptControl.open()}> 713 - <Menu.ItemText>{_(msg`Block account`)}</Menu.ItemText> 718 + <Menu.ItemText>{l`Block account`}</Menu.ItemText> 714 719 <Menu.ItemIcon icon={PersonX} position="right" /> 715 720 </Menu.Item> 716 721 )} 717 722 718 723 <Menu.Item 719 724 testID="postDropdownReportBtn" 720 - label={_(msg`Report post`)} 725 + label={l`Report post`} 721 726 onPress={() => reportDialogControl.open()}> 722 - <Menu.ItemText>{_(msg`Report post`)}</Menu.ItemText> 727 + <Menu.ItemText>{l`Report post`}</Menu.ItemText> 723 728 <Menu.ItemIcon icon={Warning} position="right" /> 724 729 </Menu.Item> 725 730 </> ··· 729 734 <> 730 735 <Menu.Item 731 736 testID="postDropdownEditPostInteractions" 732 - label={_(msg`Edit interaction settings`)} 737 + label={l`Edit interaction settings`} 733 738 onPress={() => postInteractionSettingsDialogControl.open()} 734 739 {...(isAuthor 735 740 ? Platform.select({ ··· 742 747 }) 743 748 : {})}> 744 749 <Menu.ItemText> 745 - {_(msg`Edit interaction settings`)} 750 + {l`Edit interaction settings`} 746 751 </Menu.ItemText> 747 752 <Menu.ItemIcon icon={Gear} position="right" /> 748 753 </Menu.Item> 749 754 <Menu.Item 750 755 testID="postDropdownDeleteBtn" 751 - label={_(msg`Delete post`)} 756 + label={l`Delete post`} 752 757 onPress={() => deletePromptControl.open()}> 753 - <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> 758 + <Menu.ItemText>{l`Delete post`}</Menu.ItemText> 754 759 <Menu.ItemIcon icon={Trash} position="right" /> 755 760 </Menu.Item> 756 761 </> ··· 759 764 </> 760 765 )} 761 766 </Menu.Outer> 762 - 763 767 <Prompt.Basic 764 768 control={deletePromptControl} 765 - title={_(msg`Delete this post?`)} 766 - description={_( 767 - msg`If you remove this post, you won't be able to recover it.`, 768 - )} 769 + title={l`Delete this post?`} 770 + description={l`If you remove this post, you won't be able to recover it.`} 769 771 onConfirm={onDeletePost} 770 - confirmButtonCta={_(msg`Delete`)} 772 + confirmButtonCta={l`Delete`} 771 773 confirmButtonColor="negative" 772 774 /> 773 - 774 775 <Prompt.Basic 775 776 control={hidePromptControl} 776 - title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)} 777 - description={_( 778 - msg`This post will be hidden from feeds and threads. This cannot be undone.`, 779 - )} 777 + title={isReply ? l`Hide this reply?` : l`Hide this post?`} 778 + description={l`This post will be hidden from feeds and threads. This cannot be undone.`} 780 779 onConfirm={onHidePost} 781 - confirmButtonCta={_(msg`Hide`)} 780 + confirmButtonCta={l`Hide`} 782 781 /> 783 - 784 782 <ReportDialog 785 783 control={reportDialogControl} 786 784 subject={{ ··· 788 786 $type: 'app.bsky.feed.defs#postView', 789 787 }} 790 788 /> 791 - 792 789 <PostInteractionSettingsDialog 793 790 control={postInteractionSettingsDialogControl} 794 791 postUri={post.uri} 795 792 rootPostUri={rootUri} 796 793 initialThreadgateView={post.threadgate} 797 794 /> 798 - 799 795 <Prompt.Basic 800 796 control={quotePostDetachConfirmControl} 801 - title={_(msg`Detach quote post?`)} 802 - description={_( 803 - msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, 804 - )} 797 + title={l`Detach quote post?`} 798 + description={l`This will remove your post from this quote post for all users, and replace it with a placeholder.`} 805 799 onConfirm={() => void onToggleQuotePostAttachment()} 806 - confirmButtonCta={_(msg`Yes, detach`)} 800 + confirmButtonCta={l`Yes, detach`} 807 801 /> 808 - 809 802 <Prompt.Basic 810 803 control={hideReplyConfirmControl} 811 - title={_(msg`Hide this reply?`)} 812 - description={_( 813 - msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`, 814 - )} 804 + title={l`Hide this reply?`} 805 + description={l`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`} 815 806 onConfirm={() => void onToggleReplyVisibility()} 816 - confirmButtonCta={_(msg`Yes, hide`)} 807 + confirmButtonCta={l`Yes, hide`} 817 808 /> 818 - 819 809 <Prompt.Basic 820 810 control={blockPromptControl} 821 - title={_(msg`Block Account?`)} 822 - description={_( 823 - msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, 824 - )} 811 + title={l`Block Account?`} 812 + description={l`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`} 825 813 onConfirm={() => void onBlockAuthor()} 826 - confirmButtonCta={_(msg`Block`)} 814 + confirmButtonCta={l`Block`} 827 815 confirmButtonColor="negative" 828 816 /> 829 817 </>
+6 -4
src/components/PostControls/PostMenu/index.tsx
··· 6 6 type AppBskyFeedThreadgate, 7 7 type RichText as RichTextAPI, 8 8 } from '@atproto/api' 9 - import {msg} from '@lingui/core/macro' 10 - import {useLingui} from '@lingui/react' 9 + import {useLingui} from '@lingui/react/macro' 11 10 12 11 import {type Shadow} from '#/state/cache/post-shadow' 13 12 import {EventStopper} from '#/view/com/util/EventStopper' ··· 30 29 onShowLess, 31 30 hitSlop, 32 31 logContext, 32 + forceGoogleTranslate, 33 33 }: { 34 34 testID: string 35 35 post: Shadow<AppBskyFeedDefs.PostView> ··· 43 43 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 44 44 hitSlop?: Insets 45 45 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 46 + forceGoogleTranslate: boolean 46 47 }): React.ReactNode => { 47 - const {_} = useLingui() 48 + const {t: l} = useLingui() 48 49 49 50 const menuControl = useMenuControl() 50 51 const [hasBeenOpen, setHasBeenOpen] = useState(false) ··· 63 64 return ( 64 65 <EventStopper onKeyDown={false}> 65 66 <Menu.Root control={lazyMenuControl}> 66 - <Menu.Trigger label={_(msg`Open post options menu`)}> 67 + <Menu.Trigger label={l`Open post options menu`}> 67 68 {({props}) => { 68 69 return ( 69 70 <PostControlButton ··· 90 91 threadgateRecord={threadgateRecord} 91 92 onShowLess={onShowLess} 92 93 logContext={logContext} 94 + forceGoogleTranslate={forceGoogleTranslate} 93 95 /> 94 96 )} 95 97 </Menu.Root>
+38 -48
src/components/PostControls/index.tsx
··· 6 6 type AppBskyFeedThreadgate, 7 7 type RichText as RichTextAPI, 8 8 } from '@atproto/api' 9 - import {msg, plural} from '@lingui/core/macro' 10 - import {useLingui} from '@lingui/react' 9 + import {plural} from '@lingui/core/macro' 10 + import {useLingui} from '@lingui/react/macro' 11 11 12 12 import {CountWheel} from '#/lib/custom-animations/CountWheel' 13 13 import {AnimatedLikeIcon} from '#/lib/custom-animations/LikeIcon' ··· 55 55 onShowLess, 56 56 viaRepost, 57 57 variant, 58 + forceGoogleTranslate = false, 58 59 }: { 59 60 big?: boolean 60 61 post: Shadow<AppBskyFeedDefs.PostView> ··· 70 71 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 71 72 viaRepost?: {uri: string; cid: string} 72 73 variant?: 'compact' | 'normal' | 'large' 74 + forceGoogleTranslate?: boolean 73 75 }): React.ReactNode => { 74 76 const ax = useAnalytics() 75 - const {_} = useLingui() 77 + const {t: l} = useLingui() 76 78 const {openComposer} = useOpenComposer() 77 79 const {feedDescriptor} = useFeedFeedbackContext() 78 80 const [queueLike, queueUnlike] = usePostLikeMutationQueue( ··· 104 106 105 107 const onPressToggleLike = async () => { 106 108 if (isBlocked) { 107 - Toast.show( 108 - _(msg`Cannot interact with a blocked user`), 109 - 'exclamation-circle', 110 - ) 109 + Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle') 111 110 return 112 111 } 113 112 ··· 126 125 } else { 127 126 await queueUnlike() 128 127 } 129 - } catch (e: any) { 128 + } catch (err) { 129 + const e = err as Error 130 130 if (e?.name !== 'AbortError') { 131 131 throw e 132 132 } ··· 135 135 136 136 const onRepost = async () => { 137 137 if (isBlocked) { 138 - Toast.show( 139 - _(msg`Cannot interact with a blocked user`), 140 - 'exclamation-circle', 141 - ) 138 + Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle') 142 139 return 143 140 } 144 141 ··· 154 151 } else { 155 152 await queueUnrepost() 156 153 } 157 - } catch (e: any) { 154 + } catch (err) { 155 + const e = err as Error 158 156 if (e?.name !== 'AbortError') { 159 157 throw e 160 158 } ··· 163 161 164 162 const onQuote = () => { 165 163 if (isBlocked) { 166 - Toast.show( 167 - _(msg`Cannot interact with a blocked user`), 168 - 'exclamation-circle', 169 - ) 164 + Toast.show(l`Cannot interact with a blocked user`, 'exclamation-circle') 170 165 return 171 166 } 172 167 ··· 238 233 }) 239 234 : undefined 240 235 } 241 - label={_( 242 - msg({ 243 - message: `Reply (${plural(post.replyCount || 0, { 244 - one: '# reply', 245 - other: '# replies', 246 - })})`, 247 - comment: 248 - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 249 - }), 250 - )} 236 + label={l({ 237 + message: `Reply (${plural(post.replyCount || 0, { 238 + one: '# reply', 239 + other: '# replies', 240 + })})`, 241 + comment: 242 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 243 + })} 251 244 big={big}> 252 245 <PostControlButtonIcon icon={Bubble} /> 253 246 {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( ··· 261 254 <RepostButton 262 255 isReposted={!!post.viewer?.repost} 263 256 repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 264 - onRepost={onRepost} 257 + onRepost={() => void onRepost()} 265 258 onQuote={onQuote} 266 259 big={big} 267 260 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} ··· 274 267 onPress={() => requireAuth(() => onPressToggleLike())} 275 268 label={ 276 269 post.viewer?.like 277 - ? _( 278 - msg({ 279 - message: `Unlike (${plural(post.likeCount || 0, { 280 - one: '# like', 281 - other: '# likes', 282 - })})`, 283 - comment: 284 - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 285 - }), 286 - ) 287 - : _( 288 - msg({ 289 - message: `Like (${plural(post.likeCount || 0, { 290 - one: '# like', 291 - other: '# likes', 292 - })})`, 293 - comment: 294 - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 295 - }), 296 - ) 270 + ? l({ 271 + message: `Unlike (${plural(post.likeCount || 0, { 272 + one: '# like', 273 + other: '# likes', 274 + })})`, 275 + comment: 276 + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 277 + }) 278 + : l({ 279 + message: `Like (${plural(post.likeCount || 0, { 280 + one: '# like', 281 + other: '# likes', 282 + })})`, 283 + comment: 284 + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 285 + }) 297 286 }> 298 287 <AnimatedLikeIcon 299 288 isLiked={Boolean(post.viewer?.like)} ··· 350 339 left: secondaryControlSpacingStyles.gap / 2, 351 340 }} 352 341 logContext={logContext} 342 + forceGoogleTranslate={forceGoogleTranslate} 353 343 /> 354 344 </View> 355 345 </View>
+4 -4
src/components/dms/MessageContextMenu.tsx
··· 6 6 import {useLingui} from '@lingui/react' 7 7 import {useQueryClient} from '@tanstack/react-query' 8 8 9 - import {useTranslate} from '#/lib/hooks/useTranslate' 9 + import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' 10 10 import {richTextToString} from '#/lib/strings/rich-text-helpers' 11 11 import {useConvoActive} from '#/state/messages/convo' 12 12 import {useLanguagePrefs} from '#/state/preferences' ··· 44 44 const reportControl = usePromptControl() 45 45 const blockOrDeleteControl = usePromptControl() 46 46 const langPrefs = useLanguagePrefs() 47 - const translate = useTranslate() 47 + const translate = useGoogleTranslate() 48 48 49 49 const isFromSelf = message.sender?.did === currentAccount?.did 50 50 ··· 57 57 true, 58 58 ) 59 59 60 - Clipboard.setStringAsync(str) 60 + void Clipboard.setStringAsync(str) 61 61 Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') 62 62 }, [_, message.text, message.facets]) 63 63 64 64 const onPressTranslateMessage = useCallback(() => { 65 - translate(message.text, langPrefs.primaryLanguage) 65 + void translate(message.text, langPrefs.primaryLanguage) 66 66 67 67 ax.metric('translate', { 68 68 sourceLanguages: [],
+3
src/env/index.ts
··· 49 49 export const IS_HIGH_DPI: boolean = true 50 50 // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now 51 51 export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26 52 + // So we can avoid attempting on-device translation when we know it's unsupported. 53 + export const HAS_ON_DEVICE_TRANSLATION: boolean = 54 + (IS_IOS && iOSMajorVersion >= 18) || IS_ANDROID
+1
src/env/index.web.ts
··· 48 48 '(min-resolution: 2dppx)', 49 49 ).matches 50 50 export const IS_LIQUID_GLASS: boolean = false 51 + export const HAS_ON_DEVICE_TRANSLATION: boolean = false
+2 -3
src/lib/hooks/useTranslate.ts src/lib/hooks/useGoogleTranslate.ts
··· 6 6 import {IS_ANDROID} from '#/env' 7 7 8 8 /** 9 - * Will always link out to Google Translate. If inline translation is desired, 10 - * use `useTranslateOnDevice` 9 + * @deprecated Will always link out to Google Translate. Prefer `useTranslate`. 11 10 */ 12 - export function useTranslate() { 11 + export function useGoogleTranslate() { 13 12 const openLink = useOpenLink() 14 13 15 14 return useCallback(
+16
src/lib/translation/context.ts
··· 1 + import {createContext} from 'react' 2 + 3 + import {type TranslationFunctionParams, type TranslationState} from './types' 4 + 5 + export const Context = createContext<{ 6 + translationState: Record<string, TranslationState> 7 + translate: ( 8 + parameters: TranslationFunctionParams & { 9 + key: string 10 + forceGoogleTranslate: boolean 11 + }, 12 + ) => Promise<void> 13 + clearTranslation: (key: string) => void 14 + acquireTranslation: (key: string) => () => void 15 + } | null>(null) 16 + Context.displayName = 'TranslationContext'
+282
src/lib/translation/index.tsx
··· 1 + import {useCallback, useContext, useEffect, useMemo, useState} from 'react' 2 + import {LayoutAnimation, Platform} from 'react-native' 3 + import {getLocales} from 'expo-localization' 4 + import {onTranslateTask} from '@bsky.app/expo-translate-text' 5 + import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' 6 + import {useLingui} from '@lingui/react/macro' 7 + import {useFocusEffect} from '@react-navigation/native' 8 + 9 + import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' 10 + import {logger} from '#/logger' 11 + import {useAnalytics} from '#/analytics' 12 + import {HAS_ON_DEVICE_TRANSLATION} from '#/env' 13 + import {Context} from './context' 14 + import {type TranslationFunctionParams, type TranslationState} from './types' 15 + import {guessLanguage} from './utils' 16 + 17 + export * from './types' 18 + export * from './utils' 19 + 20 + /** 21 + * Attempts on-device translation via @bsky.app/expo-translate-text. 22 + * Uses a lazy import to avoid crashing if the native module isn't linked into 23 + * the current build. 24 + */ 25 + async function attemptTranslation( 26 + input: string, 27 + targetLangCodeOriginal: string, 28 + sourceLangCodeOriginal?: string, // Auto-detects if not provided 29 + ): Promise<{ 30 + translatedText: string 31 + targetLanguage: TranslationTaskResult['targetLanguage'] 32 + sourceLanguage: TranslationTaskResult['sourceLanguage'] 33 + }> { 34 + // Note that Android only supports two-character language codes and will fail 35 + // on other input. 36 + // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage 37 + let targetLangCode = 38 + Platform.OS === 'android' 39 + ? targetLangCodeOriginal.split('-')[0] 40 + : targetLangCodeOriginal 41 + const sourceLangCode = 42 + Platform.OS === 'android' 43 + ? sourceLangCodeOriginal?.split('-')[0] 44 + : sourceLangCodeOriginal 45 + 46 + // Special cases for regional languages since iOS differentiates and missing 47 + // language packs must be downloaded and installed. 48 + if (Platform.OS === 'ios') { 49 + const deviceLocales = getLocales() 50 + const primaryLanguageTag = deviceLocales[0]?.languageTag 51 + switch (targetLangCodeOriginal) { 52 + case 'en': // en-US, en-GB 53 + case 'es': // es-419, es-ES 54 + case 'pt': // pt-BR, pt-PT 55 + case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW 56 + if ( 57 + primaryLanguageTag && 58 + primaryLanguageTag.startsWith(targetLangCodeOriginal) 59 + ) { 60 + targetLangCode = primaryLanguageTag 61 + } 62 + break 63 + } 64 + } 65 + 66 + const result = await onTranslateTask({ 67 + input, 68 + targetLangCode, 69 + sourceLangCode, 70 + }) 71 + 72 + // Since `input` is always a string, the result should always be a string. 73 + const translatedText = 74 + typeof result.translatedTexts === 'string' ? result.translatedTexts : '' 75 + 76 + if (translatedText === input) { 77 + throw new Error('Translation result is the same as the source text.') 78 + } 79 + 80 + if (translatedText === '') { 81 + throw new Error('Translation result is empty.') 82 + } 83 + 84 + return { 85 + translatedText, 86 + targetLanguage: result.targetLanguage, 87 + sourceLanguage: 88 + result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language 89 + } 90 + } 91 + 92 + /** 93 + * Native translation hook. Attempts on-device translation using Apple 94 + * Translation (iOS 18+) or Google ML Kit (Android). 95 + * 96 + * Falls back to Google Translate URL if the language pack is unavailable. 97 + * 98 + * Web uses index.web.ts which always opens Google Translate. 99 + */ 100 + export function useTranslate({ 101 + key, 102 + forceGoogleTranslate = false, 103 + }: { 104 + key: string 105 + forceGoogleTranslate?: boolean 106 + }) { 107 + const context = useContext(Context) 108 + if (!context) { 109 + throw new Error( 110 + 'useTranslate must be used within a TranslateOnDeviceProvider', 111 + ) 112 + } 113 + 114 + useFocusEffect( 115 + useCallback(() => { 116 + const cleanup = context.acquireTranslation(key) 117 + return cleanup 118 + }, [key, context]), 119 + ) 120 + 121 + const translate = useCallback( 122 + async (params: TranslationFunctionParams) => { 123 + return context.translate({...params, key, forceGoogleTranslate}) 124 + }, 125 + [context, forceGoogleTranslate, key], 126 + ) 127 + 128 + const clearTranslation = useCallback( 129 + () => context.clearTranslation(key), 130 + [context, key], 131 + ) 132 + 133 + return useMemo( 134 + () => ({ 135 + translationState: context.translationState[key] ?? { 136 + status: 'idle', 137 + }, 138 + translate, 139 + clearTranslation, 140 + }), 141 + [clearTranslation, context.translationState, key, translate], 142 + ) 143 + } 144 + 145 + export function Provider({children}: React.PropsWithChildren<unknown>) { 146 + const [translationState, setTranslationState] = useState< 147 + Record<string, TranslationState> 148 + >({}) 149 + const [refCounts, setRefCounts] = useState<Record<string, number>>({}) 150 + const ax = useAnalytics() 151 + const {t: l} = useLingui() 152 + const googleTranslate = useGoogleTranslate() 153 + 154 + useEffect(() => { 155 + setTranslationState(prev => { 156 + const keysToDelete: string[] = [] 157 + 158 + for (const key of Object.keys(prev)) { 159 + if ((refCounts[key] ?? 0) <= 0) { 160 + keysToDelete.push(key) 161 + } 162 + } 163 + 164 + if (keysToDelete.length > 0) { 165 + const newState = {...prev} 166 + keysToDelete.forEach(key => { 167 + delete newState[key] 168 + }) 169 + return newState 170 + } 171 + 172 + return prev 173 + }) 174 + }, [refCounts]) 175 + 176 + const acquireTranslation = useCallback((key: string) => { 177 + setRefCounts(prev => ({ 178 + ...prev, 179 + [key]: (prev[key] ?? 0) + 1, 180 + })) 181 + 182 + return () => { 183 + setRefCounts(prev => { 184 + const newCount = (prev[key] ?? 1) - 1 185 + if (newCount <= 0) { 186 + const {[key]: _, ...rest} = prev 187 + return rest 188 + } 189 + return {...prev, [key]: newCount} 190 + }) 191 + } 192 + }, []) 193 + 194 + const clearTranslation = useCallback((key: string) => { 195 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 196 + setTranslationState(prev => { 197 + delete prev[key] 198 + return {...prev} 199 + }) 200 + }, []) 201 + 202 + const translate = useCallback( 203 + async ({ 204 + key, 205 + text, 206 + targetLangCode, 207 + sourceLangCode, 208 + ...options 209 + }: { 210 + key: string 211 + text: string 212 + targetLangCode: string 213 + sourceLangCode?: string 214 + forceGoogleTranslate?: boolean 215 + }) => { 216 + if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) { 217 + ax.metric('translate:result', { 218 + method: 'google-translate', 219 + os: Platform.OS, 220 + sourceLanguage: sourceLangCode ?? null, 221 + targetLanguage: targetLangCode, 222 + }) 223 + await googleTranslate(text, targetLangCode, sourceLangCode) 224 + return 225 + } 226 + 227 + // Translate after the next state change. 228 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 229 + setTranslationState(prev => ({ 230 + ...prev, 231 + [key]: {status: 'loading'}, 232 + })) 233 + try { 234 + const result = await attemptTranslation( 235 + text, 236 + targetLangCode, 237 + sourceLangCode, 238 + ) 239 + ax.metric('translate:result', { 240 + method: 'on-device', 241 + os: Platform.OS, 242 + sourceLanguage: result.sourceLanguage, 243 + targetLanguage: result.targetLanguage, 244 + }) 245 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 246 + setTranslationState(prev => ({ 247 + ...prev, 248 + [key]: { 249 + status: 'success', 250 + translatedText: result.translatedText, 251 + sourceLanguage: result.sourceLanguage, 252 + targetLanguage: result.targetLanguage, 253 + }, 254 + })) 255 + } catch (e) { 256 + logger.error('Failed to translate post on device', {safeMessage: e}) 257 + // On-device translation failed (language pack missing or user 258 + // dismissed the download prompt). Fall back to Google Translate. 259 + ax.metric('translate:result', { 260 + method: 'fallback-alert', 261 + os: Platform.OS, 262 + sourceLanguage: sourceLangCode ?? null, 263 + targetLanguage: targetLangCode, 264 + }) 265 + let errorMessage = l`Device failed to translate :(` 266 + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 267 + setTranslationState(prev => ({ 268 + ...prev, 269 + [key]: {status: 'error', message: errorMessage}, 270 + })) 271 + } 272 + }, 273 + [ax, googleTranslate, l], 274 + ) 275 + 276 + const ctx = useMemo( 277 + () => ({acquireTranslation, clearTranslation, translate, translationState}), 278 + [acquireTranslation, clearTranslation, translate, translationState], 279 + ) 280 + 281 + return <Context.Provider value={ctx}>{children}</Context.Provider> 282 + }
+86
src/lib/translation/index.web.tsx
··· 1 + import {useCallback, useContext, useMemo} from 'react' 2 + 3 + import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' 4 + import {useAnalytics} from '#/analytics' 5 + import {Context} from './context' 6 + import {type TranslationFunctionParams, type TranslationState} from './types' 7 + 8 + export * from './types' 9 + export * from './utils' 10 + 11 + const translationState: Record<string, TranslationState> = {} 12 + const acquireTranslation = (_key: string) => { 13 + return () => {} 14 + } 15 + const clearTranslation = (_key: string) => {} 16 + 17 + /** 18 + * Web always opens Google Translate. 19 + */ 20 + export function useTranslate({ 21 + key, 22 + }: { 23 + key: string 24 + forceGoogleTranslate?: boolean 25 + }) { 26 + const context = useContext(Context) 27 + if (!context) { 28 + throw new Error( 29 + 'useTranslate must be used within a TranslateOnDeviceProvider', 30 + ) 31 + } 32 + 33 + // Always call hooks in consistent order 34 + const translate = useCallback( 35 + async (params: TranslationFunctionParams) => { 36 + return context.translate({...params, key, forceGoogleTranslate: true}) 37 + }, 38 + [key, context], 39 + ) 40 + 41 + const clearTranslation = useCallback(() => { 42 + return context.clearTranslation(key) 43 + }, [key, context]) 44 + 45 + return { 46 + translationState: context.translationState[key] ?? { 47 + status: 'idle' as const, 48 + }, 49 + translate, 50 + clearTranslation, 51 + } 52 + } 53 + 54 + export function Provider({children}: React.PropsWithChildren<unknown>) { 55 + const ax = useAnalytics() 56 + const googleTranslate = useGoogleTranslate() 57 + 58 + const translate = useCallback( 59 + async ({ 60 + text, 61 + targetLangCode, 62 + sourceLangCode, 63 + }: { 64 + key: string 65 + text: string 66 + targetLangCode: string 67 + sourceLangCode?: string 68 + }) => { 69 + ax.metric('translate:result', { 70 + method: 'google-translate', 71 + os: 'web', 72 + sourceLanguage: sourceLangCode ?? null, 73 + targetLanguage: targetLangCode, 74 + }) 75 + await googleTranslate(text, targetLangCode, sourceLangCode) 76 + }, 77 + [ax, googleTranslate], 78 + ) 79 + 80 + const ctx = useMemo( 81 + () => ({acquireTranslation, clearTranslation, translate, translationState}), 82 + [translate], 83 + ) 84 + 85 + return <Context.Provider value={ctx}>{children}</Context.Provider> 86 + }
+34
src/lib/translation/types.ts
··· 1 + import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' 2 + 3 + export type TranslationState = 4 + | {status: 'idle'} 5 + | {status: 'loading'} 6 + | { 7 + status: 'success' 8 + translatedText: string 9 + sourceLanguage: TranslationTaskResult['sourceLanguage'] 10 + targetLanguage: TranslationTaskResult['targetLanguage'] 11 + } 12 + | { 13 + status: 'error' 14 + message: string 15 + } 16 + 17 + export type TranslationFunctionParams = { 18 + /** 19 + * The text to be translated. 20 + */ 21 + text: string 22 + /** 23 + * The language to translate the text into. 24 + */ 25 + targetLangCode: string 26 + /** 27 + * The source language of the text. Will auto-detect if not provided. 28 + */ 29 + sourceLangCode?: string 30 + } 31 + 32 + export type TranslationFunction = ( 33 + parameters: TranslationFunctionParams, 34 + ) => Promise<void>
+13
src/lib/translation/utils.ts
··· 1 + import lande from 'lande' 2 + 3 + import {code3ToCode2Strict} from '#/locale/helpers' 4 + 5 + // TODO: Replace with expo-guess-language 6 + export function guessLanguage(text: string): string | null { 7 + const results = lande(text) 8 + // only return high-confidence results 9 + if (results[0] && results[0][1] > 0.97) { 10 + return code3ToCode2Strict(results[0][0]) ?? null 11 + } 12 + return null 13 + }
-1014
src/locale/languages.ts
··· 2 2 code3: string 3 3 code2: string 4 4 name: string 5 - android: boolean 6 - ios: boolean 7 5 } 8 6 9 7 export enum AppLanguage { ··· 103 101 ] 104 102 105 103 // Pre-generated list using Intl.DisplayNames to localize the language name. 106 - // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage 107 - // https://developer.apple.com/documentation/foundation/nslocale/isolanguagecodes 108 104 export const LANGUAGES: Language[] = [ 109 105 { 110 106 code3: 'aar', 111 107 code2: 'aa', 112 108 name: 'Afar', 113 - android: false, 114 - ios: false, 115 109 }, 116 110 { 117 111 code3: 'abk', 118 112 code2: 'ab', 119 113 name: 'Abkhazian', 120 - android: false, 121 - ios: false, 122 114 }, 123 115 { 124 116 code3: 'ace', 125 117 code2: '', 126 118 name: 'Achinese', 127 - android: false, 128 - ios: false, 129 119 }, 130 120 { 131 121 code3: 'ach', 132 122 code2: '', 133 123 name: 'Acoli', 134 - android: false, 135 - ios: false, 136 124 }, 137 125 { 138 126 code3: 'ada', 139 127 code2: '', 140 128 name: 'Adangme', 141 - android: false, 142 - ios: false, 143 129 }, 144 130 { 145 131 code3: 'ady', 146 132 code2: '', 147 133 name: 'Adyghe; Adygei', 148 - android: false, 149 - ios: false, 150 134 }, 151 135 { 152 136 code3: 'afa', 153 137 code2: '', 154 138 name: 'Afro-Asiatic languages', 155 - android: false, 156 - ios: false, 157 139 }, 158 140 { 159 141 code3: 'afh', 160 142 code2: '', 161 143 name: 'Afrihili', 162 - android: false, 163 - ios: false, 164 144 }, 165 145 { 166 146 code3: 'afr', 167 147 code2: 'af', 168 148 name: 'Afrikaans', 169 - android: false, 170 - ios: false, 171 149 }, 172 150 { 173 151 code3: 'ain', 174 152 code2: '', 175 153 name: 'Ainu', 176 - android: false, 177 - ios: false, 178 154 }, 179 155 { 180 156 code3: 'aka', 181 157 code2: 'ak', 182 158 name: 'Akan', 183 - android: false, 184 - ios: false, 185 159 }, 186 160 { 187 161 code3: 'akk', 188 162 code2: '', 189 163 name: 'Akkadian', 190 - android: false, 191 - ios: false, 192 164 }, 193 165 { 194 166 code3: 'alb', 195 167 code2: 'sq', 196 168 name: 'Albanian', 197 - android: true, 198 - ios: false, 199 169 }, 200 170 { 201 171 code3: 'ale', 202 172 code2: '', 203 173 name: 'Aleut', 204 - android: false, 205 - ios: false, 206 174 }, 207 175 { 208 176 code3: 'alg', 209 177 code2: '', 210 178 name: 'Algonquian languages', 211 - android: false, 212 - ios: false, 213 179 }, 214 180 { 215 181 code3: 'alt', 216 182 code2: '', 217 183 name: 'Southern Altai', 218 - android: false, 219 - ios: false, 220 184 }, 221 185 { 222 186 code3: 'amh', 223 187 code2: 'am', 224 188 name: 'Amharic', 225 - android: false, 226 - ios: false, 227 189 }, 228 190 { 229 191 code3: 'ang', 230 192 code2: '', 231 193 name: 'English, Old (ca.450-1100)', 232 - android: false, 233 - ios: false, 234 194 }, 235 195 { 236 196 code3: 'anp', 237 197 code2: '', 238 198 name: 'Angika', 239 - android: false, 240 - ios: false, 241 199 }, 242 200 { 243 201 code3: 'apa', 244 202 code2: '', 245 203 name: 'Apache languages', 246 - android: false, 247 - ios: false, 248 204 }, 249 205 { 250 206 code3: 'ara', 251 207 code2: 'ar', 252 208 name: 'Arabic', 253 - android: true, 254 - ios: false, 255 209 }, 256 210 { 257 211 code3: 'arc', 258 212 code2: '', 259 213 name: 'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', 260 - android: false, 261 - ios: false, 262 214 }, 263 215 { 264 216 code3: 'arg', 265 217 code2: 'an', 266 218 name: 'Aragonese', 267 - android: false, 268 - ios: false, 269 219 }, 270 220 { 271 221 code3: 'arm', 272 222 code2: 'hy', 273 223 name: 'Armenian', 274 - android: false, 275 - ios: false, 276 224 }, 277 225 { 278 226 code3: 'arn', 279 227 code2: '', 280 228 name: 'Mapudungun; Mapuche', 281 - android: false, 282 - ios: false, 283 229 }, 284 230 { 285 231 code3: 'arp', 286 232 code2: '', 287 233 name: 'Arapaho', 288 - android: false, 289 - ios: false, 290 234 }, 291 235 { 292 236 code3: 'art', 293 237 code2: '', 294 238 name: 'Artificial languages', 295 - android: false, 296 - ios: false, 297 239 }, 298 240 { 299 241 code3: 'arw', 300 242 code2: '', 301 243 name: 'Arawak', 302 - android: false, 303 - ios: false, 304 244 }, 305 245 { 306 246 code3: 'asm', 307 247 code2: 'as', 308 248 name: 'Assamese', 309 - android: false, 310 - ios: false, 311 249 }, 312 250 { 313 251 code3: 'ast', 314 252 code2: '', 315 253 name: 'Asturian', 316 - android: false, 317 - ios: false, 318 254 }, 319 255 { 320 256 code3: 'ath', 321 257 code2: '', 322 258 name: 'Athapascan languages', 323 - android: false, 324 - ios: false, 325 259 }, 326 260 { 327 261 code3: 'aus', 328 262 code2: '', 329 263 name: 'Australian languages', 330 - android: false, 331 - ios: false, 332 264 }, 333 265 { 334 266 code3: 'ava', 335 267 code2: 'av', 336 268 name: 'Avaric', 337 - android: false, 338 - ios: false, 339 269 }, 340 270 { 341 271 code3: 'ave', 342 272 code2: 'ae', 343 273 name: 'Avestan', 344 - android: false, 345 - ios: false, 346 274 }, 347 275 { 348 276 code3: 'awa', 349 277 code2: '', 350 278 name: 'Awadhi', 351 - android: false, 352 - ios: false, 353 279 }, 354 280 { 355 281 code3: 'aym', 356 282 code2: 'ay', 357 283 name: 'Aymara', 358 - android: false, 359 - ios: false, 360 284 }, 361 285 { 362 286 code3: 'aze', 363 287 code2: 'az', 364 288 name: 'Azerbaijani', 365 - android: false, 366 - ios: false, 367 289 }, 368 290 { 369 291 code3: 'bad', 370 292 code2: '', 371 293 name: 'Banda languages', 372 - android: false, 373 - ios: false, 374 294 }, 375 295 { 376 296 code3: 'bai', 377 297 code2: '', 378 298 name: 'Bamileke languages', 379 - android: false, 380 - ios: false, 381 299 }, 382 300 { 383 301 code3: 'bak', 384 302 code2: 'ba', 385 303 name: 'Bashkir', 386 - android: false, 387 - ios: false, 388 304 }, 389 305 { 390 306 code3: 'bal', 391 307 code2: '', 392 308 name: 'Baluchi', 393 - android: false, 394 - ios: false, 395 309 }, 396 310 { 397 311 code3: 'bam', 398 312 code2: 'bm', 399 313 name: 'Bambara', 400 - android: false, 401 - ios: false, 402 314 }, 403 315 { 404 316 code3: 'ban', 405 317 code2: '', 406 318 name: 'Balinese', 407 - android: false, 408 - ios: false, 409 319 }, 410 320 { 411 321 code3: 'baq', 412 322 code2: 'eu', 413 323 name: 'Basque', 414 - android: false, 415 - ios: false, 416 324 }, 417 325 { 418 326 code3: 'bas', 419 327 code2: '', 420 328 name: 'Basa', 421 - android: false, 422 - ios: false, 423 329 }, 424 330 { 425 331 code3: 'bat', 426 332 code2: '', 427 333 name: 'Baltic languages', 428 - android: false, 429 - ios: false, 430 334 }, 431 335 { 432 336 code3: 'bej', 433 337 code2: '', 434 338 name: 'Beja; Bedawiyet', 435 - android: false, 436 - ios: false, 437 339 }, 438 340 { 439 341 code3: 'bel', 440 342 code2: 'be', 441 343 name: 'Belarusian', 442 - android: true, 443 - ios: false, 444 344 }, 445 345 { 446 346 code3: 'bem', 447 347 code2: '', 448 348 name: 'Bemba', 449 - android: false, 450 - ios: false, 451 349 }, 452 350 { 453 351 code3: 'ben', 454 352 code2: 'bn', 455 353 name: 'Bangla', 456 - android: true, 457 - ios: false, 458 354 }, 459 355 { 460 356 code3: 'ber', 461 357 code2: '', 462 358 name: 'Berber languages', 463 - android: false, 464 - ios: false, 465 359 }, 466 360 { 467 361 code3: 'bho', 468 362 code2: '', 469 363 name: 'Bhojpuri', 470 - android: false, 471 - ios: false, 472 364 }, 473 365 { 474 366 code3: 'bih', 475 367 code2: 'bh', 476 368 name: 'Bhojpuri', 477 - android: false, 478 - ios: false, 479 369 }, 480 370 { 481 371 code3: 'bik', 482 372 code2: '', 483 373 name: 'Bikol', 484 - android: false, 485 - ios: false, 486 374 }, 487 375 { 488 376 code3: 'bin', 489 377 code2: '', 490 378 name: 'Bini; Edo', 491 - android: false, 492 - ios: false, 493 379 }, 494 380 { 495 381 code3: 'bis', 496 382 code2: 'bi', 497 383 name: 'Bislama', 498 - android: false, 499 - ios: false, 500 384 }, 501 385 { 502 386 code3: 'bla', 503 387 code2: '', 504 388 name: 'Siksika', 505 - android: false, 506 - ios: false, 507 389 }, 508 390 { 509 391 code3: 'bnt', 510 392 code2: '', 511 393 name: 'Bantu languages', 512 - android: false, 513 - ios: false, 514 394 }, 515 395 { 516 396 code3: 'bod', 517 397 code2: 'bo', 518 398 name: 'Tibetan', 519 - android: false, 520 - ios: false, 521 399 }, 522 400 { 523 401 code3: 'bos', 524 402 code2: 'bs', 525 403 name: 'Bosnian', 526 - android: false, 527 - ios: false, 528 404 }, 529 405 { 530 406 code3: 'bra', 531 407 code2: '', 532 408 name: 'Braj', 533 - android: false, 534 - ios: false, 535 409 }, 536 410 { 537 411 code3: 'bre', 538 412 code2: 'br', 539 413 name: 'Breton', 540 - android: false, 541 - ios: false, 542 414 }, 543 415 { 544 416 code3: 'btk', 545 417 code2: '', 546 418 name: 'Batak languages', 547 - android: false, 548 - ios: false, 549 419 }, 550 420 { 551 421 code3: 'bua', 552 422 code2: '', 553 423 name: 'Buriat', 554 - android: false, 555 - ios: false, 556 424 }, 557 425 { 558 426 code3: 'bug', 559 427 code2: '', 560 428 name: 'Buginese', 561 - android: false, 562 - ios: false, 563 429 }, 564 430 { 565 431 code3: 'bul', 566 432 code2: 'bg', 567 433 name: 'Bulgarian', 568 - android: true, 569 - ios: false, 570 434 }, 571 435 { 572 436 code3: 'bur', 573 437 code2: 'my', 574 438 name: 'Burmese', 575 - android: false, 576 - ios: false, 577 439 }, 578 440 { 579 441 code3: 'byn', 580 442 code2: '', 581 443 name: 'Blin; Bilin', 582 - android: false, 583 - ios: false, 584 444 }, 585 445 { 586 446 code3: 'cad', 587 447 code2: '', 588 448 name: 'Caddo', 589 - android: false, 590 - ios: false, 591 449 }, 592 450 { 593 451 code3: 'cai', 594 452 code2: '', 595 453 name: 'Central American Indian languages', 596 - android: false, 597 - ios: false, 598 454 }, 599 455 { 600 456 code3: 'car', 601 457 code2: '', 602 458 name: 'Galibi Carib', 603 - android: false, 604 - ios: false, 605 459 }, 606 460 { 607 461 code3: 'cat', 608 462 code2: 'ca', 609 463 name: 'Catalan', 610 - android: true, 611 - ios: false, 612 464 }, 613 465 { 614 466 code3: 'cau', 615 467 code2: '', 616 468 name: 'Caucasian languages', 617 - android: false, 618 - ios: false, 619 469 }, 620 470 { 621 471 code3: 'ceb', 622 472 code2: '', 623 473 name: 'Cebuano', 624 - android: false, 625 - ios: false, 626 474 }, 627 475 { 628 476 code3: 'cel', 629 477 code2: '', 630 478 name: 'Celtic languages', 631 - android: false, 632 - ios: false, 633 479 }, 634 480 { 635 481 code3: 'ces', 636 482 code2: 'cs', 637 483 name: 'Czech', 638 - android: true, 639 - ios: false, 640 484 }, 641 485 { 642 486 code3: 'cha', 643 487 code2: 'ch', 644 488 name: 'Chamorro', 645 - android: false, 646 - ios: false, 647 489 }, 648 490 { 649 491 code3: 'chb', 650 492 code2: '', 651 493 name: 'Chibcha', 652 - android: false, 653 - ios: false, 654 494 }, 655 495 { 656 496 code3: 'che', 657 497 code2: 'ce', 658 498 name: 'Chechen', 659 - android: false, 660 - ios: false, 661 499 }, 662 500 { 663 501 code3: 'chg', 664 502 code2: '', 665 503 name: 'Chagatai', 666 - android: false, 667 - ios: false, 668 504 }, 669 505 { 670 506 code3: 'chi', 671 507 code2: 'zh', 672 508 name: 'Chinese', 673 - android: true, 674 - ios: false, 675 509 }, 676 510 { 677 511 code3: 'chk', 678 512 code2: '', 679 513 name: 'Chuukese', 680 - android: false, 681 - ios: false, 682 514 }, 683 515 { 684 516 code3: 'chm', 685 517 code2: '', 686 518 name: 'Mari', 687 - android: false, 688 - ios: false, 689 519 }, 690 520 { 691 521 code3: 'chn', 692 522 code2: '', 693 523 name: 'Chinook jargon', 694 - android: false, 695 - ios: false, 696 524 }, 697 525 { 698 526 code3: 'cho', 699 527 code2: '', 700 528 name: 'Choctaw', 701 - android: false, 702 - ios: false, 703 529 }, 704 530 { 705 531 code3: 'chp', 706 532 code2: '', 707 533 name: 'Chipewyan; Dene Suline', 708 - android: false, 709 - ios: false, 710 534 }, 711 535 { 712 536 code3: 'chr', 713 537 code2: '', 714 538 name: 'Cherokee', 715 - android: false, 716 - ios: false, 717 539 }, 718 540 { 719 541 code3: 'chu', 720 542 code2: 'cu', 721 543 name: 'Church Slavic', 722 - android: false, 723 - ios: false, 724 544 }, 725 545 { 726 546 code3: 'chv', 727 547 code2: 'cv', 728 548 name: 'Chuvash', 729 - android: false, 730 - ios: false, 731 549 }, 732 550 { 733 551 code3: 'chy', 734 552 code2: '', 735 553 name: 'Cheyenne', 736 - android: false, 737 - ios: false, 738 554 }, 739 555 { 740 556 code3: 'cmc', 741 557 code2: '', 742 558 name: 'Chamic languages', 743 - android: false, 744 - ios: false, 745 559 }, 746 560 { 747 561 code3: 'cnr', 748 562 code2: '', 749 563 name: 'Serbian (Montenegro)', 750 - android: false, 751 - ios: false, 752 564 }, 753 565 { 754 566 code3: 'cop', 755 567 code2: '', 756 568 name: 'Coptic', 757 - android: false, 758 - ios: false, 759 569 }, 760 570 { 761 571 code3: 'cor', 762 572 code2: 'kw', 763 573 name: 'Cornish', 764 - android: false, 765 - ios: false, 766 574 }, 767 575 { 768 576 code3: 'cos', 769 577 code2: 'co', 770 578 name: 'Corsican', 771 - android: false, 772 - ios: false, 773 579 }, 774 580 { 775 581 code3: 'cpe', 776 582 code2: '', 777 583 name: 'Creoles and pidgins, English based', 778 - android: false, 779 - ios: false, 780 584 }, 781 585 { 782 586 code3: 'cpf', 783 587 code2: '', 784 588 name: 'Creoles and pidgins, French-based', 785 - android: false, 786 - ios: false, 787 589 }, 788 590 { 789 591 code3: 'cpp', 790 592 code2: '', 791 593 name: 'Creoles and pidgins, Portuguese-based', 792 - android: false, 793 - ios: false, 794 594 }, 795 595 { 796 596 code3: 'cre', 797 597 code2: 'cr', 798 598 name: 'Cree', 799 - android: false, 800 - ios: false, 801 599 }, 802 600 { 803 601 code3: 'crh', 804 602 code2: '', 805 603 name: 'Crimean Tatar; Crimean Turkish', 806 - android: false, 807 - ios: false, 808 604 }, 809 605 { 810 606 code3: 'crp', 811 607 code2: '', 812 608 name: 'Creoles and pidgins', 813 - android: false, 814 - ios: false, 815 609 }, 816 610 { 817 611 code3: 'csb', 818 612 code2: '', 819 613 name: 'Kashubian', 820 - android: false, 821 - ios: false, 822 614 }, 823 615 { 824 616 code3: 'cus', 825 617 code2: '', 826 618 name: 'Cushitic languages', 827 - android: false, 828 - ios: false, 829 619 }, 830 620 { 831 621 code3: 'cym', 832 622 code2: 'cy', 833 623 name: 'Welsh', 834 - android: true, 835 - ios: false, 836 624 }, 837 625 { 838 626 code3: 'cze', 839 627 code2: 'cs', 840 628 name: 'Czech', 841 - android: true, 842 - ios: false, 843 629 }, 844 630 { 845 631 code3: 'dak', 846 632 code2: '', 847 633 name: 'Dakota', 848 - android: false, 849 - ios: false, 850 634 }, 851 635 { 852 636 code3: 'dan', 853 637 code2: 'da', 854 638 name: 'Danish', 855 - android: true, 856 - ios: false, 857 639 }, 858 640 { 859 641 code3: 'dar', 860 642 code2: '', 861 643 name: 'Dargwa', 862 - android: false, 863 - ios: false, 864 644 }, 865 645 { 866 646 code3: 'day', 867 647 code2: '', 868 648 name: 'Land Dayak languages', 869 - android: false, 870 - ios: false, 871 649 }, 872 650 { 873 651 code3: 'del', 874 652 code2: '', 875 653 name: 'Delaware', 876 - android: false, 877 - ios: false, 878 654 }, 879 655 { 880 656 code3: 'den', 881 657 code2: '', 882 658 name: 'Slave (Athapascan)', 883 - android: false, 884 - ios: false, 885 659 }, 886 660 { 887 661 code3: 'deu', 888 662 code2: 'de', 889 663 name: 'German', 890 - android: true, 891 - ios: true, 892 664 }, 893 665 { 894 666 code3: 'dgr', 895 667 code2: '', 896 668 name: 'Dogrib', 897 - android: false, 898 - ios: false, 899 669 }, 900 670 { 901 671 code3: 'din', 902 672 code2: '', 903 673 name: 'Dinka', 904 - android: false, 905 - ios: false, 906 674 }, 907 675 { 908 676 code3: 'div', 909 677 code2: 'dv', 910 678 name: 'Divehi', 911 - android: false, 912 - ios: false, 913 679 }, 914 680 { 915 681 code3: 'doi', 916 682 code2: '', 917 683 name: 'Dogri', 918 - android: false, 919 - ios: false, 920 684 }, 921 685 { 922 686 code3: 'dra', 923 687 code2: '', 924 688 name: 'Dravidian languages', 925 - android: false, 926 - ios: false, 927 689 }, 928 690 { 929 691 code3: 'dsb', 930 692 code2: '', 931 693 name: 'Lower Sorbian', 932 - android: false, 933 - ios: false, 934 694 }, 935 695 { 936 696 code3: 'dua', 937 697 code2: '', 938 698 name: 'Duala', 939 - android: false, 940 - ios: false, 941 699 }, 942 700 { 943 701 code3: 'dum', 944 702 code2: '', 945 703 name: 'Dutch, Middle (ca.1050-1350)', 946 - android: false, 947 - ios: false, 948 704 }, 949 705 { 950 706 code3: 'dut', 951 707 code2: 'nl', 952 708 name: 'Dutch', 953 - android: true, 954 - ios: true, 955 709 }, 956 710 { 957 711 code3: 'dyu', 958 712 code2: '', 959 713 name: 'Dyula', 960 - android: false, 961 - ios: false, 962 714 }, 963 715 { 964 716 code3: 'dzo', 965 717 code2: 'dz', 966 718 name: 'Dzongkha', 967 - android: false, 968 - ios: false, 969 719 }, 970 720 { 971 721 code3: 'efi', 972 722 code2: '', 973 723 name: 'Efik', 974 - android: false, 975 - ios: false, 976 724 }, 977 725 { 978 726 code3: 'egy', 979 727 code2: '', 980 728 name: 'Egyptian (Ancient)', 981 - android: false, 982 - ios: false, 983 729 }, 984 730 { 985 731 code3: 'eka', 986 732 code2: '', 987 733 name: 'Ekajuk', 988 - android: false, 989 - ios: false, 990 734 }, 991 735 { 992 736 code3: 'ell', 993 737 code2: 'el', 994 738 name: 'Greek', 995 - android: true, 996 - ios: false, 997 739 }, 998 740 { 999 741 code3: 'elx', 1000 742 code2: '', 1001 743 name: 'Elamite', 1002 - android: false, 1003 - ios: false, 1004 744 }, 1005 745 { 1006 746 code3: 'eng', 1007 747 code2: 'en', 1008 748 name: 'English', 1009 - android: true, 1010 - ios: true, 1011 749 }, 1012 750 { 1013 751 code3: 'enm', 1014 752 code2: '', 1015 753 name: 'English, Middle (1100-1500)', 1016 - android: false, 1017 - ios: false, 1018 754 }, 1019 755 { 1020 756 code3: 'epo', 1021 757 code2: 'eo', 1022 758 name: 'Esperanto', 1023 - android: true, 1024 - ios: false, 1025 759 }, 1026 760 { 1027 761 code3: 'est', 1028 762 code2: 'et', 1029 763 name: 'Estonian', 1030 - android: true, 1031 - ios: false, 1032 764 }, 1033 765 { 1034 766 code3: 'eus', 1035 767 code2: 'eu', 1036 768 name: 'Basque', 1037 - android: false, 1038 - ios: false, 1039 769 }, 1040 770 { 1041 771 code3: 'ewe', 1042 772 code2: 'ee', 1043 773 name: 'Ewe', 1044 - android: false, 1045 - ios: false, 1046 774 }, 1047 775 { 1048 776 code3: 'ewo', 1049 777 code2: '', 1050 778 name: 'Ewondo', 1051 - android: false, 1052 - ios: false, 1053 779 }, 1054 780 { 1055 781 code3: 'fan', 1056 782 code2: '', 1057 783 name: 'Fang', 1058 - android: false, 1059 - ios: false, 1060 784 }, 1061 785 { 1062 786 code3: 'fao', 1063 787 code2: 'fo', 1064 788 name: 'Faroese', 1065 - android: false, 1066 - ios: false, 1067 789 }, 1068 790 { 1069 791 code3: 'fas', 1070 792 code2: 'fa', 1071 793 name: 'Persian', 1072 - android: true, 1073 - ios: false, 1074 794 }, 1075 795 { 1076 796 code3: 'fat', 1077 797 code2: '', 1078 798 name: 'Akan', 1079 - android: false, 1080 - ios: false, 1081 799 }, 1082 800 { 1083 801 code3: 'fij', 1084 802 code2: 'fj', 1085 803 name: 'Fijian', 1086 - android: false, 1087 - ios: false, 1088 804 }, 1089 805 { 1090 806 code3: 'fil', 1091 807 code2: '', 1092 808 name: 'Filipino', 1093 - android: false, 1094 - ios: false, 1095 809 }, 1096 810 { 1097 811 code3: 'fin', 1098 812 code2: 'fi', 1099 813 name: 'Finnish', 1100 - android: true, 1101 - ios: false, 1102 814 }, 1103 815 { 1104 816 code3: 'fiu', 1105 817 code2: '', 1106 818 name: 'Finno-Ugrian languages', 1107 - android: false, 1108 - ios: false, 1109 819 }, 1110 820 { 1111 821 code3: 'fon', 1112 822 code2: '', 1113 823 name: 'Fon', 1114 - android: false, 1115 - ios: false, 1116 824 }, 1117 825 { 1118 826 code3: 'fra', 1119 827 code2: 'fr', 1120 828 name: 'French', 1121 - android: true, 1122 - ios: true, 1123 829 }, 1124 830 { 1125 831 code3: 'fre', 1126 832 code2: 'fr', 1127 833 name: 'French', 1128 - android: true, 1129 - ios: true, 1130 834 }, 1131 835 { 1132 836 code3: 'frm', 1133 837 code2: '', 1134 838 name: 'French, Middle (ca.1400-1600)', 1135 - android: false, 1136 - ios: false, 1137 839 }, 1138 840 { 1139 841 code3: 'fro', 1140 842 code2: '', 1141 843 name: 'French, Old (842-ca.1400)', 1142 - android: false, 1143 - ios: false, 1144 844 }, 1145 845 { 1146 846 code3: 'frr', 1147 847 code2: '', 1148 848 name: 'Northern Frisian', 1149 - android: false, 1150 - ios: false, 1151 849 }, 1152 850 { 1153 851 code3: 'frs', 1154 852 code2: '', 1155 853 name: 'Eastern Frisian', 1156 - android: false, 1157 - ios: false, 1158 854 }, 1159 855 { 1160 856 code3: 'fry', 1161 857 code2: 'fy', 1162 858 name: 'Western Frisian', 1163 - android: false, 1164 - ios: false, 1165 859 }, 1166 860 { 1167 861 code3: 'ful', 1168 862 code2: 'ff', 1169 863 name: 'Fulah', 1170 - android: false, 1171 - ios: false, 1172 864 }, 1173 865 { 1174 866 code3: 'fur', 1175 867 code2: '', 1176 868 name: 'Friulian', 1177 - android: false, 1178 - ios: false, 1179 869 }, 1180 870 { 1181 871 code3: 'gaa', 1182 872 code2: '', 1183 873 name: 'Ga', 1184 - android: false, 1185 - ios: false, 1186 874 }, 1187 875 { 1188 876 code3: 'gay', 1189 877 code2: '', 1190 878 name: 'Gayo', 1191 - android: false, 1192 - ios: false, 1193 879 }, 1194 880 { 1195 881 code3: 'gba', 1196 882 code2: '', 1197 883 name: 'Gbaya', 1198 - android: false, 1199 - ios: false, 1200 884 }, 1201 885 { 1202 886 code3: 'gem', 1203 887 code2: '', 1204 888 name: 'Germanic languages', 1205 - android: false, 1206 - ios: false, 1207 889 }, 1208 890 { 1209 891 code3: 'geo', 1210 892 code2: 'ka', 1211 893 name: 'Georgian', 1212 - android: true, 1213 - ios: false, 1214 894 }, 1215 895 { 1216 896 code3: 'ger', 1217 897 code2: 'de', 1218 898 name: 'German', 1219 - android: true, 1220 - ios: true, 1221 899 }, 1222 900 { 1223 901 code3: 'gez', 1224 902 code2: '', 1225 903 name: 'Geez', 1226 - android: false, 1227 - ios: false, 1228 904 }, 1229 905 { 1230 906 code3: 'gil', 1231 907 code2: '', 1232 908 name: 'Gilbertese', 1233 - android: false, 1234 - ios: false, 1235 909 }, 1236 910 { 1237 911 code3: 'gla', 1238 912 code2: 'gd', 1239 913 name: 'Scottish Gaelic', 1240 - android: false, 1241 - ios: false, 1242 914 }, 1243 915 { 1244 916 code3: 'gle', 1245 917 code2: 'ga', 1246 918 name: 'Irish', 1247 - android: true, 1248 - ios: false, 1249 919 }, 1250 920 { 1251 921 code3: 'glg', 1252 922 code2: 'gl', 1253 923 name: 'Galician', 1254 - android: true, 1255 - ios: false, 1256 924 }, 1257 925 { 1258 926 code3: 'glv', 1259 927 code2: 'gv', 1260 928 name: 'Manx', 1261 - android: false, 1262 - ios: false, 1263 929 }, 1264 930 { 1265 931 code3: 'gmh', 1266 932 code2: '', 1267 933 name: 'German, Middle High (ca.1050-1500)', 1268 - android: false, 1269 - ios: false, 1270 934 }, 1271 935 { 1272 936 code3: 'goh', 1273 937 code2: '', 1274 938 name: 'German, Old High (ca.750-1050)', 1275 - android: false, 1276 - ios: false, 1277 939 }, 1278 940 { 1279 941 code3: 'gon', 1280 942 code2: '', 1281 943 name: 'Gondi', 1282 - android: false, 1283 - ios: false, 1284 944 }, 1285 945 { 1286 946 code3: 'gor', 1287 947 code2: '', 1288 948 name: 'Gorontalo', 1289 - android: false, 1290 - ios: false, 1291 949 }, 1292 950 { 1293 951 code3: 'got', 1294 952 code2: '', 1295 953 name: 'Gothic', 1296 - android: false, 1297 - ios: false, 1298 954 }, 1299 955 { 1300 956 code3: 'grb', 1301 957 code2: '', 1302 958 name: 'Grebo', 1303 - android: false, 1304 - ios: false, 1305 959 }, 1306 960 { 1307 961 code3: 'grc', 1308 962 code2: '', 1309 963 name: 'Ancient Greek', 1310 - android: false, 1311 - ios: false, 1312 964 }, 1313 965 { 1314 966 code3: 'gre', 1315 967 code2: 'el', 1316 968 name: 'Greek', 1317 - android: true, 1318 - ios: false, 1319 969 }, 1320 970 { 1321 971 code3: 'grn', 1322 972 code2: 'gn', 1323 973 name: 'Guarani', 1324 - android: false, 1325 - ios: false, 1326 974 }, 1327 975 { 1328 976 code3: 'gsw', 1329 977 code2: '', 1330 978 name: 'Swiss German; Alemannic; Alsatian', 1331 - android: false, 1332 - ios: false, 1333 979 }, 1334 980 { 1335 981 code3: 'guj', 1336 982 code2: 'gu', 1337 983 name: 'Gujarati', 1338 - android: true, 1339 - ios: false, 1340 984 }, 1341 985 { 1342 986 code3: 'gwi', 1343 987 code2: '', 1344 988 name: "Gwich'in", 1345 - android: false, 1346 - ios: false, 1347 989 }, 1348 990 { 1349 991 code3: 'hai', 1350 992 code2: '', 1351 993 name: 'Haida', 1352 - android: false, 1353 - ios: false, 1354 994 }, 1355 995 { 1356 996 code3: 'hat', 1357 997 code2: 'ht', 1358 998 name: 'Haitian Creole', 1359 - android: true, 1360 - ios: false, 1361 999 }, 1362 1000 { 1363 1001 code3: 'hau', 1364 1002 code2: 'ha', 1365 1003 name: 'Hausa', 1366 - android: false, 1367 - ios: false, 1368 1004 }, 1369 1005 { 1370 1006 code3: 'haw', 1371 1007 code2: '', 1372 1008 name: 'Hawaiian', 1373 - android: false, 1374 - ios: false, 1375 1009 }, 1376 1010 { 1377 1011 code3: 'heb', 1378 1012 code2: 'he', 1379 1013 name: 'Hebrew', 1380 - android: true, 1381 - ios: false, 1382 1014 }, 1383 1015 { 1384 1016 code3: 'her', 1385 1017 code2: 'hz', 1386 1018 name: 'Herero', 1387 - android: false, 1388 - ios: false, 1389 1019 }, 1390 1020 { 1391 1021 code3: 'hil', 1392 1022 code2: '', 1393 1023 name: 'Hiligaynon', 1394 - android: false, 1395 - ios: false, 1396 1024 }, 1397 1025 { 1398 1026 code3: 'him', 1399 1027 code2: '', 1400 1028 name: 'Himachali languages; Western Pahari languages', 1401 - android: false, 1402 - ios: false, 1403 1029 }, 1404 1030 { 1405 1031 code3: 'hin', 1406 1032 code2: 'hi', 1407 1033 name: 'Hindi', 1408 - android: true, 1409 - ios: true, 1410 1034 }, 1411 1035 { 1412 1036 code3: 'hit', 1413 1037 code2: '', 1414 1038 name: 'Hittite', 1415 - android: false, 1416 - ios: false, 1417 1039 }, 1418 1040 { 1419 1041 code3: 'hmn', 1420 1042 code2: '', 1421 1043 name: 'Hmong', 1422 - android: false, 1423 - ios: false, 1424 1044 }, 1425 1045 { 1426 1046 code3: 'hmo', 1427 1047 code2: 'ho', 1428 1048 name: 'Hiri Motu', 1429 - android: false, 1430 - ios: false, 1431 1049 }, 1432 1050 { 1433 1051 code3: 'hrv', 1434 1052 code2: 'hr', 1435 1053 name: 'Croatian', 1436 - android: true, 1437 - ios: false, 1438 1054 }, 1439 1055 { 1440 1056 code3: 'hsb', 1441 1057 code2: '', 1442 1058 name: 'Upper Sorbian', 1443 - android: false, 1444 - ios: false, 1445 1059 }, 1446 1060 { 1447 1061 code3: 'hun', 1448 1062 code2: 'hu', 1449 1063 name: 'Hungarian', 1450 - android: true, 1451 - ios: false, 1452 1064 }, 1453 1065 { 1454 1066 code3: 'hup', 1455 1067 code2: '', 1456 1068 name: 'Hupa', 1457 - android: false, 1458 - ios: false, 1459 1069 }, 1460 1070 { 1461 1071 code3: 'hye', 1462 1072 code2: 'hy', 1463 1073 name: 'Armenian', 1464 - android: false, 1465 - ios: false, 1466 1074 }, 1467 1075 { 1468 1076 code3: 'iba', 1469 1077 code2: '', 1470 1078 name: 'Iban', 1471 - android: false, 1472 - ios: false, 1473 1079 }, 1474 1080 { 1475 1081 code3: 'ibo', 1476 1082 code2: 'ig', 1477 1083 name: 'Igbo', 1478 - android: false, 1479 - ios: false, 1480 1084 }, 1481 1085 { 1482 1086 code3: 'ice', 1483 1087 code2: 'is', 1484 1088 name: 'Icelandic', 1485 - android: true, 1486 - ios: false, 1487 1089 }, 1488 1090 { 1489 1091 code3: 'ido', 1490 1092 code2: 'io', 1491 1093 name: 'Ido', 1492 - android: false, 1493 - ios: false, 1494 1094 }, 1495 1095 { 1496 1096 code3: 'iii', 1497 1097 code2: 'ii', 1498 1098 name: 'Sichuan Yi; Nuosu', 1499 - android: false, 1500 - ios: false, 1501 1099 }, 1502 1100 { 1503 1101 code3: 'ijo', 1504 1102 code2: '', 1505 1103 name: 'Ijo languages', 1506 - android: false, 1507 - ios: false, 1508 1104 }, 1509 1105 { 1510 1106 code3: 'iku', 1511 1107 code2: 'iu', 1512 1108 name: 'Inuktitut', 1513 - android: false, 1514 - ios: false, 1515 1109 }, 1516 1110 { 1517 1111 code3: 'ile', 1518 1112 code2: 'ie', 1519 1113 name: 'Interlingue', 1520 - android: false, 1521 - ios: false, 1522 1114 }, 1523 1115 { 1524 1116 code3: 'ilo', 1525 1117 code2: '', 1526 1118 name: 'Iloko', 1527 - android: false, 1528 - ios: false, 1529 1119 }, 1530 1120 { 1531 1121 code3: 'ina', 1532 1122 code2: 'ia', 1533 1123 name: 'Interlingua', 1534 - android: false, 1535 - ios: false, 1536 1124 }, 1537 1125 { 1538 1126 code3: 'inc', 1539 1127 code2: '', 1540 1128 name: 'Indic languages', 1541 - android: false, 1542 - ios: false, 1543 1129 }, 1544 1130 { 1545 1131 code3: 'ind', 1546 1132 code2: 'id', 1547 1133 name: 'Indonesian', 1548 - android: true, 1549 - ios: false, 1550 1134 }, 1551 1135 { 1552 1136 code3: 'ine', 1553 1137 code2: '', 1554 1138 name: 'Indo-European languages', 1555 - android: false, 1556 - ios: false, 1557 1139 }, 1558 1140 { 1559 1141 code3: 'inh', 1560 1142 code2: '', 1561 1143 name: 'Ingush', 1562 - android: false, 1563 - ios: false, 1564 1144 }, 1565 1145 { 1566 1146 code3: 'ipk', 1567 1147 code2: 'ik', 1568 1148 name: 'Inupiaq', 1569 - android: false, 1570 - ios: false, 1571 1149 }, 1572 1150 { 1573 1151 code3: 'ira', 1574 1152 code2: '', 1575 1153 name: 'Iranian languages', 1576 - android: false, 1577 - ios: false, 1578 1154 }, 1579 1155 { 1580 1156 code3: 'iro', 1581 1157 code2: '', 1582 1158 name: 'Iroquoian languages', 1583 - android: false, 1584 - ios: false, 1585 1159 }, 1586 1160 { 1587 1161 code3: 'isl', 1588 1162 code2: 'is', 1589 1163 name: 'Icelandic', 1590 - android: true, 1591 - ios: false, 1592 1164 }, 1593 1165 { 1594 1166 code3: 'ita', 1595 1167 code2: 'it', 1596 1168 name: 'Italian', 1597 - android: true, 1598 - ios: true, 1599 1169 }, 1600 1170 { 1601 1171 code3: 'jav', 1602 1172 code2: 'jv', 1603 1173 name: 'Javanese', 1604 - android: false, 1605 - ios: false, 1606 1174 }, 1607 1175 { 1608 1176 code3: 'jbo', 1609 1177 code2: '', 1610 1178 name: 'Lojban', 1611 - android: false, 1612 - ios: false, 1613 1179 }, 1614 1180 { 1615 1181 code3: 'jpn', 1616 1182 code2: 'ja', 1617 1183 name: 'Japanese', 1618 - android: true, 1619 - ios: true, 1620 1184 }, 1621 1185 { 1622 1186 code3: 'jpr', 1623 1187 code2: '', 1624 1188 name: 'Judeo-Persian', 1625 - android: false, 1626 - ios: false, 1627 1189 }, 1628 1190 { 1629 1191 code3: 'jrb', 1630 1192 code2: '', 1631 1193 name: 'Judeo-Arabic', 1632 - android: false, 1633 - ios: false, 1634 1194 }, 1635 1195 { 1636 1196 code3: 'kaa', 1637 1197 code2: '', 1638 1198 name: 'Kara-Kalpak', 1639 - android: false, 1640 - ios: false, 1641 1199 }, 1642 1200 { 1643 1201 code3: 'kab', 1644 1202 code2: '', 1645 1203 name: 'Kabyle', 1646 - android: false, 1647 - ios: false, 1648 1204 }, 1649 1205 { 1650 1206 code3: 'kac', 1651 1207 code2: '', 1652 1208 name: 'Kachin; Jingpho', 1653 - android: false, 1654 - ios: false, 1655 1209 }, 1656 1210 { 1657 1211 code3: 'kal', 1658 1212 code2: 'kl', 1659 1213 name: 'Kalaallisut', 1660 - android: false, 1661 - ios: false, 1662 1214 }, 1663 1215 { 1664 1216 code3: 'kam', 1665 1217 code2: '', 1666 1218 name: 'Kamba', 1667 - android: false, 1668 - ios: false, 1669 1219 }, 1670 1220 { 1671 1221 code3: 'kan', 1672 1222 code2: 'kn', 1673 1223 name: 'Kannada', 1674 - android: true, 1675 - ios: false, 1676 1224 }, 1677 1225 { 1678 1226 code3: 'kar', 1679 1227 code2: '', 1680 1228 name: 'Karen languages', 1681 - android: false, 1682 - ios: false, 1683 1229 }, 1684 1230 { 1685 1231 code3: 'kas', 1686 1232 code2: 'ks', 1687 1233 name: 'Kashmiri', 1688 - android: false, 1689 - ios: false, 1690 1234 }, 1691 1235 { 1692 1236 code3: 'kat', 1693 1237 code2: 'ka', 1694 1238 name: 'Georgian', 1695 - android: true, 1696 - ios: false, 1697 1239 }, 1698 1240 { 1699 1241 code3: 'kau', 1700 1242 code2: 'kr', 1701 1243 name: 'Kanuri', 1702 - android: false, 1703 - ios: false, 1704 1244 }, 1705 1245 { 1706 1246 code3: 'kaw', 1707 1247 code2: '', 1708 1248 name: 'Kawi', 1709 - android: false, 1710 - ios: false, 1711 1249 }, 1712 1250 { 1713 1251 code3: 'kaz', 1714 1252 code2: 'kk', 1715 1253 name: 'Kazakh', 1716 - android: false, 1717 - ios: false, 1718 1254 }, 1719 1255 { 1720 1256 code3: 'kbd', 1721 1257 code2: '', 1722 1258 name: 'Kabardian', 1723 - android: false, 1724 - ios: false, 1725 1259 }, 1726 1260 { 1727 1261 code3: 'kha', 1728 1262 code2: '', 1729 1263 name: 'Khasi', 1730 - android: false, 1731 - ios: false, 1732 1264 }, 1733 1265 { 1734 1266 code3: 'khi', 1735 1267 code2: '', 1736 1268 name: 'Khoisan languages', 1737 - android: false, 1738 - ios: false, 1739 1269 }, 1740 1270 { 1741 1271 code3: 'khm', 1742 1272 code2: 'km', 1743 1273 name: 'Khmer', 1744 - android: false, 1745 - ios: false, 1746 1274 }, 1747 1275 { 1748 1276 code3: 'kho', 1749 1277 code2: '', 1750 1278 name: 'Khotanese; Sakan', 1751 - android: false, 1752 - ios: false, 1753 1279 }, 1754 1280 { 1755 1281 code3: 'kik', 1756 1282 code2: 'ki', 1757 1283 name: 'Kikuyu; Gikuyu', 1758 - android: false, 1759 - ios: false, 1760 1284 }, 1761 1285 { 1762 1286 code3: 'kin', 1763 1287 code2: 'rw', 1764 1288 name: 'Kinyarwanda', 1765 - android: false, 1766 - ios: false, 1767 1289 }, 1768 1290 { 1769 1291 code3: 'kir', 1770 1292 code2: 'ky', 1771 1293 name: 'Kyrgyz', 1772 - android: false, 1773 - ios: false, 1774 1294 }, 1775 1295 { 1776 1296 code3: 'kmb', 1777 1297 code2: '', 1778 1298 name: 'Kimbundu', 1779 - android: false, 1780 - ios: false, 1781 1299 }, 1782 1300 { 1783 1301 code3: 'kok', 1784 1302 code2: '', 1785 1303 name: 'Konkani', 1786 - android: false, 1787 - ios: false, 1788 1304 }, 1789 1305 { 1790 1306 code3: 'kom', 1791 1307 code2: 'kv', 1792 1308 name: 'Komi', 1793 - android: false, 1794 - ios: false, 1795 1309 }, 1796 1310 { 1797 1311 code3: 'kon', 1798 1312 code2: 'kg', 1799 1313 name: 'Kongo', 1800 - android: false, 1801 - ios: false, 1802 1314 }, 1803 1315 { 1804 1316 code3: 'kor', 1805 1317 code2: 'ko', 1806 1318 name: 'Korean', 1807 - android: true, 1808 - ios: true, 1809 1319 }, 1810 1320 { 1811 1321 code3: 'kos', 1812 1322 code2: '', 1813 1323 name: 'Kosraean', 1814 - android: false, 1815 - ios: false, 1816 1324 }, 1817 1325 { 1818 1326 code3: 'kpe', 1819 1327 code2: '', 1820 1328 name: 'Kpelle', 1821 - android: false, 1822 - ios: false, 1823 1329 }, 1824 1330 { 1825 1331 code3: 'krc', 1826 1332 code2: '', 1827 1333 name: 'Karachay-Balkar', 1828 - android: false, 1829 - ios: false, 1830 1334 }, 1831 1335 { 1832 1336 code3: 'krl', 1833 1337 code2: '', 1834 1338 name: 'Karelian', 1835 - android: false, 1836 - ios: false, 1837 1339 }, 1838 1340 { 1839 1341 code3: 'kro', 1840 1342 code2: '', 1841 1343 name: 'Kru languages', 1842 - android: false, 1843 - ios: false, 1844 1344 }, 1845 1345 { 1846 1346 code3: 'kru', 1847 1347 code2: '', 1848 1348 name: 'Kurukh', 1849 - android: false, 1850 - ios: false, 1851 1349 }, 1852 1350 { 1853 1351 code3: 'kua', 1854 1352 code2: 'kj', 1855 1353 name: 'Kuanyama; Kwanyama', 1856 - android: false, 1857 - ios: false, 1858 1354 }, 1859 1355 { 1860 1356 code3: 'kum', 1861 1357 code2: '', 1862 1358 name: 'Kumyk', 1863 - android: false, 1864 - ios: false, 1865 1359 }, 1866 1360 { 1867 1361 code3: 'kur', 1868 1362 code2: 'ku', 1869 1363 name: 'Kurdish', 1870 - android: false, 1871 - ios: false, 1872 1364 }, 1873 1365 { 1874 1366 code3: 'kut', 1875 1367 code2: '', 1876 1368 name: 'Kutenai', 1877 - android: false, 1878 - ios: false, 1879 1369 }, 1880 1370 { 1881 1371 code3: 'lad', 1882 1372 code2: '', 1883 1373 name: 'Ladino', 1884 - android: false, 1885 - ios: false, 1886 1374 }, 1887 1375 { 1888 1376 code3: 'lah', 1889 1377 code2: '', 1890 1378 name: 'Lahnda', 1891 - android: false, 1892 - ios: false, 1893 1379 }, 1894 1380 { 1895 1381 code3: 'lam', 1896 1382 code2: '', 1897 1383 name: 'Lamba', 1898 - android: false, 1899 - ios: false, 1900 1384 }, 1901 1385 { 1902 1386 code3: 'lao', 1903 1387 code2: 'lo', 1904 1388 name: 'Lao', 1905 - android: false, 1906 - ios: false, 1907 1389 }, 1908 1390 { 1909 1391 code3: 'lat', 1910 1392 code2: 'la', 1911 1393 name: 'Latin', 1912 - android: false, 1913 - ios: false, 1914 1394 }, 1915 1395 { 1916 1396 code3: 'lav', 1917 1397 code2: 'lv', 1918 1398 name: 'Latvian', 1919 - android: true, 1920 - ios: false, 1921 1399 }, 1922 1400 { 1923 1401 code3: 'lez', 1924 1402 code2: '', 1925 1403 name: 'Lezghian', 1926 - android: false, 1927 - ios: false, 1928 1404 }, 1929 1405 { 1930 1406 code3: 'lim', 1931 1407 code2: 'li', 1932 1408 name: 'Limburgish', 1933 - android: false, 1934 - ios: false, 1935 1409 }, 1936 1410 { 1937 1411 code3: 'lin', 1938 1412 code2: 'ln', 1939 1413 name: 'Lingala', 1940 - android: false, 1941 - ios: false, 1942 1414 }, 1943 1415 { 1944 1416 code3: 'lit', 1945 1417 code2: 'lt', 1946 1418 name: 'Lithuanian', 1947 - android: true, 1948 - ios: false, 1949 1419 }, 1950 1420 { 1951 1421 code3: 'lol', 1952 1422 code2: '', 1953 1423 name: 'Mongo', 1954 - android: false, 1955 - ios: false, 1956 1424 }, 1957 1425 { 1958 1426 code3: 'loz', 1959 1427 code2: '', 1960 1428 name: 'Lozi', 1961 - android: false, 1962 - ios: false, 1963 1429 }, 1964 1430 { 1965 1431 code3: 'ltz', 1966 1432 code2: 'lb', 1967 1433 name: 'Luxembourgish', 1968 - android: false, 1969 - ios: false, 1970 1434 }, 1971 1435 { 1972 1436 code3: 'lua', 1973 1437 code2: '', 1974 1438 name: 'Luba-Lulua', 1975 - android: false, 1976 - ios: false, 1977 1439 }, 1978 1440 { 1979 1441 code3: 'lub', 1980 1442 code2: 'lu', 1981 1443 name: 'Luba-Katanga', 1982 - android: false, 1983 - ios: false, 1984 1444 }, 1985 1445 { 1986 1446 code3: 'lug', 1987 1447 code2: 'lg', 1988 1448 name: 'Ganda', 1989 - android: false, 1990 - ios: false, 1991 1449 }, 1992 1450 { 1993 1451 code3: 'lui', 1994 1452 code2: '', 1995 1453 name: 'Luiseno', 1996 - android: false, 1997 - ios: false, 1998 1454 }, 1999 1455 { 2000 1456 code3: 'lun', 2001 1457 code2: '', 2002 1458 name: 'Lunda', 2003 - android: false, 2004 - ios: false, 2005 1459 }, 2006 1460 { 2007 1461 code3: 'luo', 2008 1462 code2: '', 2009 1463 name: 'Luo (Kenya and Tanzania)', 2010 - android: false, 2011 - ios: false, 2012 1464 }, 2013 1465 { 2014 1466 code3: 'lus', 2015 1467 code2: '', 2016 1468 name: 'Mizo', 2017 - android: false, 2018 - ios: false, 2019 1469 }, 2020 1470 { 2021 1471 code3: 'mac', 2022 1472 code2: 'mk', 2023 1473 name: 'Macedonian', 2024 - android: true, 2025 - ios: false, 2026 1474 }, 2027 1475 { 2028 1476 code3: 'mad', 2029 1477 code2: '', 2030 1478 name: 'Madurese', 2031 - android: false, 2032 - ios: false, 2033 1479 }, 2034 1480 { 2035 1481 code3: 'mag', 2036 1482 code2: '', 2037 1483 name: 'Magahi', 2038 - android: false, 2039 - ios: false, 2040 1484 }, 2041 1485 { 2042 1486 code3: 'mah', 2043 1487 code2: 'mh', 2044 1488 name: 'Marshallese', 2045 - android: false, 2046 - ios: false, 2047 1489 }, 2048 1490 { 2049 1491 code3: 'mai', 2050 1492 code2: '', 2051 1493 name: 'Maithili', 2052 - android: false, 2053 - ios: false, 2054 1494 }, 2055 1495 { 2056 1496 code3: 'mak', 2057 1497 code2: '', 2058 1498 name: 'Makasar', 2059 - android: false, 2060 - ios: false, 2061 1499 }, 2062 1500 { 2063 1501 code3: 'mal', 2064 1502 code2: 'ml', 2065 1503 name: 'Malayalam', 2066 - android: false, 2067 - ios: false, 2068 1504 }, 2069 1505 { 2070 1506 code3: 'man', 2071 1507 code2: '', 2072 1508 name: 'Mandingo', 2073 - android: false, 2074 - ios: false, 2075 1509 }, 2076 1510 { 2077 1511 code3: 'mao', 2078 1512 code2: 'mi', 2079 1513 name: 'Māori', 2080 - android: false, 2081 - ios: false, 2082 1514 }, 2083 1515 { 2084 1516 code3: 'map', 2085 1517 code2: '', 2086 1518 name: 'Austronesian languages', 2087 - android: false, 2088 - ios: false, 2089 1519 }, 2090 1520 { 2091 1521 code3: 'mar', 2092 1522 code2: 'mr', 2093 1523 name: 'Marathi', 2094 - android: true, 2095 - ios: false, 2096 1524 }, 2097 1525 { 2098 1526 code3: 'mas', 2099 1527 code2: '', 2100 1528 name: 'Masai', 2101 - android: false, 2102 - ios: false, 2103 1529 }, 2104 1530 { 2105 1531 code3: 'may', 2106 1532 code2: 'ms', 2107 1533 name: 'Malay', 2108 - android: true, 2109 - ios: false, 2110 1534 }, 2111 1535 { 2112 1536 code3: 'mdf', 2113 1537 code2: '', 2114 1538 name: 'Moksha', 2115 - android: false, 2116 - ios: false, 2117 1539 }, 2118 1540 { 2119 1541 code3: 'mdr', 2120 1542 code2: '', 2121 1543 name: 'Mandar', 2122 - android: false, 2123 - ios: false, 2124 1544 }, 2125 1545 { 2126 1546 code3: 'men', 2127 1547 code2: '', 2128 1548 name: 'Mende', 2129 - android: false, 2130 - ios: false, 2131 1549 }, 2132 1550 { 2133 1551 code3: 'mga', 2134 1552 code2: '', 2135 1553 name: 'Irish, Middle (900-1200)', 2136 - android: false, 2137 - ios: false, 2138 1554 }, 2139 1555 { 2140 1556 code3: 'mic', 2141 1557 code2: '', 2142 1558 name: "Mi'kmaq; Micmac", 2143 - android: false, 2144 - ios: false, 2145 1559 }, 2146 1560 { 2147 1561 code3: 'min', 2148 1562 code2: '', 2149 1563 name: 'Minangkabau', 2150 - android: false, 2151 - ios: false, 2152 1564 }, 2153 1565 { 2154 1566 code3: 'mis', 2155 1567 code2: '', 2156 1568 name: 'Uncoded languages', 2157 - android: false, 2158 - ios: false, 2159 1569 }, 2160 1570 { 2161 1571 code3: 'mkd', 2162 1572 code2: 'mk', 2163 1573 name: 'Macedonian', 2164 - android: true, 2165 - ios: false, 2166 1574 }, 2167 1575 { 2168 1576 code3: 'mkh', 2169 1577 code2: '', 2170 1578 name: 'Mon-Khmer languages', 2171 - android: false, 2172 - ios: false, 2173 1579 }, 2174 1580 { 2175 1581 code3: 'mlg', 2176 1582 code2: 'mg', 2177 1583 name: 'Malagasy', 2178 - android: false, 2179 - ios: false, 2180 1584 }, 2181 1585 { 2182 1586 code3: 'mlt', 2183 1587 code2: 'mt', 2184 1588 name: 'Maltese', 2185 - android: true, 2186 - ios: false, 2187 1589 }, 2188 1590 { 2189 1591 code3: 'mnc', 2190 1592 code2: '', 2191 1593 name: 'Manchu', 2192 - android: false, 2193 - ios: false, 2194 1594 }, 2195 1595 { 2196 1596 code3: 'mni', 2197 1597 code2: '', 2198 1598 name: 'Manipuri', 2199 - android: false, 2200 - ios: false, 2201 1599 }, 2202 1600 { 2203 1601 code3: 'mno', 2204 1602 code2: '', 2205 1603 name: 'Manobo languages', 2206 - android: false, 2207 - ios: false, 2208 1604 }, 2209 1605 { 2210 1606 code3: 'moh', 2211 1607 code2: '', 2212 1608 name: 'Mohawk', 2213 - android: false, 2214 - ios: false, 2215 1609 }, 2216 1610 { 2217 1611 code3: 'mon', 2218 1612 code2: 'mn', 2219 1613 name: 'Mongolian', 2220 - android: false, 2221 - ios: false, 2222 1614 }, 2223 1615 { 2224 1616 code3: 'mos', 2225 1617 code2: '', 2226 1618 name: 'Mossi', 2227 - android: false, 2228 - ios: false, 2229 1619 }, 2230 1620 { 2231 1621 code3: 'mri', 2232 1622 code2: 'mi', 2233 1623 name: 'Māori', 2234 - android: false, 2235 - ios: false, 2236 1624 }, 2237 1625 { 2238 1626 code3: 'msa', 2239 1627 code2: 'ms', 2240 1628 name: 'Malay', 2241 - android: true, 2242 - ios: false, 2243 1629 }, 2244 1630 { 2245 1631 code3: 'mul', 2246 1632 code2: '', 2247 1633 name: 'Multiple languages', 2248 - android: false, 2249 - ios: false, 2250 1634 }, 2251 1635 { 2252 1636 code3: 'mun', 2253 1637 code2: '', 2254 1638 name: 'Munda languages', 2255 - android: false, 2256 - ios: false, 2257 1639 }, 2258 1640 { 2259 1641 code3: 'mus', 2260 1642 code2: '', 2261 1643 name: 'Creek', 2262 - android: false, 2263 - ios: false, 2264 1644 }, 2265 1645 { 2266 1646 code3: 'mwl', 2267 1647 code2: '', 2268 1648 name: 'Mirandese', 2269 - android: false, 2270 - ios: false, 2271 1649 }, 2272 1650 { 2273 1651 code3: 'mwr', 2274 1652 code2: '', 2275 1653 name: 'Marwari', 2276 - android: false, 2277 - ios: false, 2278 1654 }, 2279 1655 { 2280 1656 code3: 'mya', 2281 1657 code2: 'my', 2282 1658 name: 'Burmese', 2283 - android: false, 2284 - ios: false, 2285 1659 }, 2286 1660 { 2287 1661 code3: 'myn', 2288 1662 code2: '', 2289 1663 name: 'Mayan languages', 2290 - android: false, 2291 - ios: false, 2292 1664 }, 2293 1665 { 2294 1666 code3: 'myv', 2295 1667 code2: '', 2296 1668 name: 'Erzya', 2297 - android: false, 2298 - ios: false, 2299 1669 }, 2300 1670 { 2301 1671 code3: 'nah', 2302 1672 code2: '', 2303 1673 name: 'Nahuatl languages', 2304 - android: false, 2305 - ios: false, 2306 1674 }, 2307 1675 { 2308 1676 code3: 'nai', 2309 1677 code2: '', 2310 1678 name: 'North American Indian languages', 2311 - android: false, 2312 - ios: false, 2313 1679 }, 2314 1680 { 2315 1681 code3: 'nap', 2316 1682 code2: '', 2317 1683 name: 'Neapolitan', 2318 - android: false, 2319 - ios: false, 2320 1684 }, 2321 1685 { 2322 1686 code3: 'nau', 2323 1687 code2: 'na', 2324 1688 name: 'Nauru', 2325 - android: false, 2326 - ios: false, 2327 1689 }, 2328 1690 { 2329 1691 code3: 'nav', 2330 1692 code2: 'nv', 2331 1693 name: 'Navajo', 2332 - android: false, 2333 - ios: false, 2334 1694 }, 2335 1695 { 2336 1696 code3: 'nbl', 2337 1697 code2: 'nr', 2338 1698 name: 'South Ndebele', 2339 - android: false, 2340 - ios: false, 2341 1699 }, 2342 1700 { 2343 1701 code3: 'nde', 2344 1702 code2: 'nd', 2345 1703 name: 'North Ndebele', 2346 - android: false, 2347 - ios: false, 2348 1704 }, 2349 1705 { 2350 1706 code3: 'ndo', 2351 1707 code2: 'ng', 2352 1708 name: 'Ndonga', 2353 - android: false, 2354 - ios: false, 2355 1709 }, 2356 1710 { 2357 1711 code3: 'nds', 2358 1712 code2: '', 2359 1713 name: 'Low German; Low Saxon; German, Low; Saxon, Low', 2360 - android: false, 2361 - ios: false, 2362 1714 }, 2363 1715 { 2364 1716 code3: 'nep', 2365 1717 code2: 'ne', 2366 1718 name: 'Nepali', 2367 - android: false, 2368 - ios: false, 2369 1719 }, 2370 1720 { 2371 1721 code3: 'new', 2372 1722 code2: '', 2373 1723 name: 'Nepal Bhasa; Newari', 2374 - android: false, 2375 - ios: false, 2376 1724 }, 2377 1725 { 2378 1726 code3: 'nia', 2379 1727 code2: '', 2380 1728 name: 'Nias', 2381 - android: false, 2382 - ios: false, 2383 1729 }, 2384 1730 { 2385 1731 code3: 'nic', 2386 1732 code2: '', 2387 1733 name: 'Niger-Kordofanian languages', 2388 - android: false, 2389 - ios: false, 2390 1734 }, 2391 1735 { 2392 1736 code3: 'niu', 2393 1737 code2: '', 2394 1738 name: 'Niuean', 2395 - android: false, 2396 - ios: false, 2397 1739 }, 2398 1740 { 2399 1741 code3: 'nld', 2400 1742 code2: 'nl', 2401 1743 name: 'Dutch', 2402 - android: true, 2403 - ios: true, 2404 1744 }, 2405 1745 { 2406 1746 code3: 'nno', 2407 1747 code2: 'nn', 2408 1748 name: 'Norwegian Nynorsk', 2409 - android: false, 2410 - ios: false, 2411 1749 }, 2412 1750 { 2413 1751 code3: 'nob', 2414 1752 code2: 'nb', 2415 1753 name: 'Norwegian Bokmål', 2416 - android: false, 2417 - ios: false, 2418 1754 }, 2419 1755 { 2420 1756 code3: 'nog', 2421 1757 code2: '', 2422 1758 name: 'Nogai', 2423 - android: false, 2424 - ios: false, 2425 1759 }, 2426 1760 { 2427 1761 code3: 'non', 2428 1762 code2: '', 2429 1763 name: 'Norse, Old', 2430 - android: false, 2431 - ios: false, 2432 1764 }, 2433 1765 { 2434 1766 code3: 'nor', 2435 1767 code2: 'no', 2436 1768 name: 'Norwegian', 2437 - android: true, 2438 - ios: false, 2439 1769 }, 2440 1770 { 2441 1771 code3: 'nqo', 2442 1772 code2: '', 2443 1773 name: "N'Ko", 2444 - android: false, 2445 - ios: false, 2446 1774 }, 2447 1775 { 2448 1776 code3: 'nso', 2449 1777 code2: '', 2450 1778 name: 'Northern Sotho', 2451 - android: false, 2452 - ios: false, 2453 1779 }, 2454 1780 { 2455 1781 code3: 'nub', 2456 1782 code2: '', 2457 1783 name: 'Nubian languages', 2458 - android: false, 2459 - ios: false, 2460 1784 }, 2461 1785 { 2462 1786 code3: 'nwc', 2463 1787 code2: '', 2464 1788 name: 'Classical Newari; Old Newari; Classical Nepal Bhasa', 2465 - android: false, 2466 - ios: false, 2467 1789 }, 2468 1790 { 2469 1791 code3: 'nya', 2470 1792 code2: 'ny', 2471 1793 name: 'Nyanja', 2472 - android: false, 2473 - ios: false, 2474 1794 }, 2475 1795 { 2476 1796 code3: 'nym', 2477 1797 code2: '', 2478 1798 name: 'Nyamwezi', 2479 - android: false, 2480 - ios: false, 2481 1799 }, 2482 1800 { 2483 1801 code3: 'nyn', 2484 1802 code2: '', 2485 1803 name: 'Nyankole', 2486 - android: false, 2487 - ios: false, 2488 1804 }, 2489 1805 { 2490 1806 code3: 'nyo', 2491 1807 code2: '', 2492 1808 name: 'Nyoro', 2493 - android: false, 2494 - ios: false, 2495 1809 }, 2496 1810 { 2497 1811 code3: 'nzi', 2498 1812 code2: '', 2499 1813 name: 'Nzima', 2500 - android: false, 2501 - ios: false, 2502 1814 }, 2503 1815 { 2504 1816 code3: 'oci', 2505 1817 code2: 'oc', 2506 1818 name: 'Occitan', 2507 - android: false, 2508 - ios: false, 2509 1819 }, 2510 1820 { 2511 1821 code3: 'oji', 2512 1822 code2: 'oj', 2513 1823 name: 'Ojibwa', 2514 - android: false, 2515 - ios: false, 2516 1824 }, 2517 1825 { 2518 1826 code3: 'ori', 2519 1827 code2: 'or', 2520 1828 name: 'Odia', 2521 - android: false, 2522 - ios: false, 2523 1829 }, 2524 1830 { 2525 1831 code3: 'orm', 2526 1832 code2: 'om', 2527 1833 name: 'Oromo', 2528 - android: false, 2529 - ios: false, 2530 1834 }, 2531 1835 { 2532 1836 code3: 'osa', 2533 1837 code2: '', 2534 1838 name: 'Osage', 2535 - android: false, 2536 - ios: false, 2537 1839 }, 2538 1840 { 2539 1841 code3: 'oss', 2540 1842 code2: 'os', 2541 1843 name: 'Ossetic', 2542 - android: false, 2543 - ios: false, 2544 1844 }, 2545 1845 { 2546 1846 code3: 'ota', 2547 1847 code2: '', 2548 1848 name: 'Turkish, Ottoman (1500-1928)', 2549 - android: false, 2550 - ios: false, 2551 1849 }, 2552 1850 { 2553 1851 code3: 'oto', 2554 1852 code2: '', 2555 1853 name: 'Otomian languages', 2556 - android: false, 2557 - ios: false, 2558 1854 }, 2559 1855 { 2560 1856 code3: 'paa', 2561 1857 code2: '', 2562 1858 name: 'Papuan languages', 2563 - android: false, 2564 - ios: false, 2565 1859 }, 2566 1860 { 2567 1861 code3: 'pag', 2568 1862 code2: '', 2569 1863 name: 'Pangasinan', 2570 - android: false, 2571 - ios: false, 2572 1864 }, 2573 1865 { 2574 1866 code3: 'pal', 2575 1867 code2: '', 2576 1868 name: 'Pahlavi', 2577 - android: false, 2578 - ios: false, 2579 1869 }, 2580 1870 { 2581 1871 code3: 'pam', 2582 1872 code2: '', 2583 1873 name: 'Pampanga; Kapampangan', 2584 - android: false, 2585 - ios: false, 2586 1874 }, 2587 1875 { 2588 1876 code3: 'pan', 2589 1877 code2: 'pa', 2590 1878 name: 'Punjabi', 2591 - android: false, 2592 - ios: false, 2593 1879 }, 2594 1880 { 2595 1881 code3: 'pap', 2596 1882 code2: '', 2597 1883 name: 'Papiamento', 2598 - android: false, 2599 - ios: false, 2600 1884 }, 2601 1885 { 2602 1886 code3: 'pau', 2603 1887 code2: '', 2604 1888 name: 'Palauan', 2605 - android: false, 2606 - ios: false, 2607 1889 }, 2608 1890 { 2609 1891 code3: 'peo', 2610 1892 code2: '', 2611 1893 name: 'Persian, Old (ca.600-400 B.C.)', 2612 - android: false, 2613 - ios: false, 2614 1894 }, 2615 1895 { 2616 1896 code3: 'per', 2617 1897 code2: 'fa', 2618 1898 name: 'Persian', 2619 - android: true, 2620 - ios: false, 2621 1899 }, 2622 1900 { 2623 1901 code3: 'phi', 2624 1902 code2: '', 2625 1903 name: 'Philippine languages', 2626 - android: false, 2627 - ios: false, 2628 1904 }, 2629 1905 { 2630 1906 code3: 'phn', 2631 1907 code2: '', 2632 1908 name: 'Phoenician', 2633 - android: false, 2634 - ios: false, 2635 1909 }, 2636 1910 { 2637 1911 code3: 'pli', 2638 1912 code2: 'pi', 2639 1913 name: 'Pali', 2640 - android: false, 2641 - ios: false, 2642 1914 }, 2643 1915 { 2644 1916 code3: 'pol', 2645 1917 code2: 'pl', 2646 1918 name: 'Polish', 2647 - android: true, 2648 - ios: true, 2649 1919 }, 2650 1920 { 2651 1921 code3: 'pon', 2652 1922 code2: '', 2653 1923 name: 'Pohnpeian', 2654 - android: false, 2655 - ios: false, 2656 1924 }, 2657 1925 { 2658 1926 code3: 'por', 2659 1927 code2: 'pt', 2660 1928 name: 'Portuguese', 2661 - android: true, 2662 - ios: true, 2663 1929 }, 2664 1930 { 2665 1931 code3: 'pra', 2666 1932 code2: '', 2667 1933 name: 'Prakrit languages', 2668 - android: false, 2669 - ios: false, 2670 1934 }, 2671 1935 { 2672 1936 code3: 'pro', 2673 1937 code2: '', 2674 1938 name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)', 2675 - android: false, 2676 - ios: false, 2677 1939 }, 2678 1940 { 2679 1941 code3: 'pus', 2680 1942 code2: 'ps', 2681 1943 name: 'Pashto', 2682 - android: false, 2683 - ios: false, 2684 1944 }, 2685 1945 { 2686 1946 code3: 'que', 2687 1947 code2: 'qu', 2688 1948 name: 'Quechua', 2689 - android: false, 2690 - ios: false, 2691 1949 }, 2692 1950 { 2693 1951 code3: 'raj', 2694 1952 code2: '', 2695 1953 name: 'Rajasthani', 2696 - android: false, 2697 - ios: false, 2698 1954 }, 2699 1955 { 2700 1956 code3: 'rap', 2701 1957 code2: '', 2702 1958 name: 'Rapanui', 2703 - android: false, 2704 - ios: false, 2705 1959 }, 2706 1960 { 2707 1961 code3: 'rar', 2708 1962 code2: '', 2709 1963 name: 'Rarotongan; Cook Islands Maori', 2710 - android: false, 2711 - ios: false, 2712 1964 }, 2713 1965 { 2714 1966 code3: 'roa', 2715 1967 code2: '', 2716 1968 name: 'Romance languages', 2717 - android: false, 2718 - ios: false, 2719 1969 }, 2720 1970 { 2721 1971 code3: 'roh', 2722 1972 code2: 'rm', 2723 1973 name: 'Romansh', 2724 - android: false, 2725 - ios: false, 2726 1974 }, 2727 1975 { 2728 1976 code3: 'rom', 2729 1977 code2: '', 2730 1978 name: 'Romany', 2731 - android: false, 2732 - ios: false, 2733 1979 }, 2734 1980 { 2735 1981 code3: 'rum', 2736 1982 code2: 'ro', 2737 1983 name: 'Romanian', 2738 - android: true, 2739 - ios: false, 2740 1984 }, 2741 1985 { 2742 1986 code3: 'ron', 2743 1987 code2: 'ro', 2744 1988 name: 'Romanian', 2745 - android: true, 2746 - ios: false, 2747 1989 }, 2748 1990 { 2749 1991 code3: 'run', 2750 1992 code2: 'rn', 2751 1993 name: 'Rundi', 2752 - android: false, 2753 - ios: false, 2754 1994 }, 2755 1995 { 2756 1996 code3: 'rup', 2757 1997 code2: '', 2758 1998 name: 'Aromanian; Arumanian; Macedo-Romanian', 2759 - android: false, 2760 - ios: false, 2761 1999 }, 2762 2000 { 2763 2001 code3: 'rus', 2764 2002 code2: 'ru', 2765 2003 name: 'Russian', 2766 - android: true, 2767 - ios: true, 2768 2004 }, 2769 2005 { 2770 2006 code3: 'sad', 2771 2007 code2: '', 2772 2008 name: 'Sandawe', 2773 - android: false, 2774 - ios: false, 2775 2009 }, 2776 2010 { 2777 2011 code3: 'sag', 2778 2012 code2: 'sg', 2779 2013 name: 'Sango', 2780 - android: false, 2781 - ios: false, 2782 2014 }, 2783 2015 { 2784 2016 code3: 'sah', 2785 2017 code2: '', 2786 2018 name: 'Yakut', 2787 - android: false, 2788 - ios: false, 2789 2019 }, 2790 2020 { 2791 2021 code3: 'sai', 2792 2022 code2: '', 2793 2023 name: 'South American Indian languages', 2794 - android: false, 2795 - ios: false, 2796 2024 }, 2797 2025 { 2798 2026 code3: 'sal', 2799 2027 code2: '', 2800 2028 name: 'Salishan languages', 2801 - android: false, 2802 - ios: false, 2803 2029 }, 2804 2030 { 2805 2031 code3: 'sam', 2806 2032 code2: '', 2807 2033 name: 'Samaritan Aramaic', 2808 - android: false, 2809 - ios: false, 2810 2034 }, 2811 2035 { 2812 2036 code3: 'san', 2813 2037 code2: 'sa', 2814 2038 name: 'Sanskrit', 2815 - android: false, 2816 - ios: false, 2817 2039 }, 2818 2040 { 2819 2041 code3: 'sas', 2820 2042 code2: '', 2821 2043 name: 'Sasak', 2822 - android: false, 2823 - ios: false, 2824 2044 }, 2825 2045 { 2826 2046 code3: 'sat', 2827 2047 code2: '', 2828 2048 name: 'Santali', 2829 - android: false, 2830 - ios: false, 2831 2049 }, 2832 2050 { 2833 2051 code3: 'scn', 2834 2052 code2: '', 2835 2053 name: 'Sicilian', 2836 - android: false, 2837 - ios: false, 2838 2054 }, 2839 2055 { 2840 2056 code3: 'sco', 2841 2057 code2: '', 2842 2058 name: 'Scots', 2843 - android: false, 2844 - ios: false, 2845 2059 }, 2846 2060 { 2847 2061 code3: 'sel', 2848 2062 code2: '', 2849 2063 name: 'Selkup', 2850 - android: false, 2851 - ios: false, 2852 2064 }, 2853 2065 { 2854 2066 code3: 'sem', 2855 2067 code2: '', 2856 2068 name: 'Semitic languages', 2857 - android: false, 2858 - ios: false, 2859 2069 }, 2860 2070 { 2861 2071 code3: 'sga', 2862 2072 code2: '', 2863 2073 name: 'Irish, Old (to 900)', 2864 - android: false, 2865 - ios: false, 2866 2074 }, 2867 2075 { 2868 2076 code3: 'sgn', 2869 2077 code2: '', 2870 2078 name: 'Sign Languages', 2871 - android: false, 2872 - ios: false, 2873 2079 }, 2874 2080 { 2875 2081 code3: 'shn', 2876 2082 code2: '', 2877 2083 name: 'Shan', 2878 - android: false, 2879 - ios: false, 2880 2084 }, 2881 2085 { 2882 2086 code3: 'sid', 2883 2087 code2: '', 2884 2088 name: 'Sidamo', 2885 - android: false, 2886 - ios: false, 2887 2089 }, 2888 2090 { 2889 2091 code3: 'sin', 2890 2092 code2: 'si', 2891 2093 name: 'Sinhala', 2892 - android: false, 2893 - ios: false, 2894 2094 }, 2895 2095 { 2896 2096 code3: 'sio', 2897 2097 code2: '', 2898 2098 name: 'Siouan languages', 2899 - android: false, 2900 - ios: false, 2901 2099 }, 2902 2100 { 2903 2101 code3: 'sit', 2904 2102 code2: '', 2905 2103 name: 'Sino-Tibetan languages', 2906 - android: false, 2907 - ios: false, 2908 2104 }, 2909 2105 { 2910 2106 code3: 'sla', 2911 2107 code2: '', 2912 2108 name: 'Slavic languages', 2913 - android: false, 2914 - ios: false, 2915 2109 }, 2916 2110 { 2917 2111 code3: 'slo', 2918 2112 code2: 'sk', 2919 2113 name: 'Slovak', 2920 - android: true, 2921 - ios: false, 2922 2114 }, 2923 2115 { 2924 2116 code3: 'slk', 2925 2117 code2: 'sk', 2926 2118 name: 'Slovak', 2927 - android: true, 2928 - ios: false, 2929 2119 }, 2930 2120 { 2931 2121 code3: 'slv', 2932 2122 code2: 'sl', 2933 2123 name: 'Slovenian', 2934 - android: true, 2935 - ios: false, 2936 2124 }, 2937 2125 { 2938 2126 code3: 'sma', 2939 2127 code2: '', 2940 2128 name: 'Southern Sami', 2941 - android: false, 2942 - ios: false, 2943 2129 }, 2944 2130 { 2945 2131 code3: 'sme', 2946 2132 code2: 'se', 2947 2133 name: 'Northern Sami', 2948 - android: false, 2949 - ios: false, 2950 2134 }, 2951 2135 { 2952 2136 code3: 'smi', 2953 2137 code2: '', 2954 2138 name: 'Sami languages', 2955 - android: false, 2956 - ios: false, 2957 2139 }, 2958 2140 { 2959 2141 code3: 'smj', 2960 2142 code2: '', 2961 2143 name: 'Lule Sami', 2962 - android: false, 2963 - ios: false, 2964 2144 }, 2965 2145 { 2966 2146 code3: 'smn', 2967 2147 code2: '', 2968 2148 name: 'Inari Sami', 2969 - android: false, 2970 - ios: false, 2971 2149 }, 2972 2150 { 2973 2151 code3: 'smo', 2974 2152 code2: 'sm', 2975 2153 name: 'Samoan', 2976 - android: false, 2977 - ios: false, 2978 2154 }, 2979 2155 { 2980 2156 code3: 'sms', 2981 2157 code2: '', 2982 2158 name: 'Skolt Sami', 2983 - android: false, 2984 - ios: false, 2985 2159 }, 2986 2160 { 2987 2161 code3: 'sna', 2988 2162 code2: 'sn', 2989 2163 name: 'Shona', 2990 - android: false, 2991 - ios: false, 2992 2164 }, 2993 2165 { 2994 2166 code3: 'snd', 2995 2167 code2: 'sd', 2996 2168 name: 'Sindhi', 2997 - android: false, 2998 - ios: false, 2999 2169 }, 3000 2170 { 3001 2171 code3: 'snk', 3002 2172 code2: '', 3003 2173 name: 'Soninke', 3004 - android: false, 3005 - ios: false, 3006 2174 }, 3007 2175 { 3008 2176 code3: 'sog', 3009 2177 code2: '', 3010 2178 name: 'Sogdian', 3011 - android: false, 3012 - ios: false, 3013 2179 }, 3014 2180 { 3015 2181 code3: 'som', 3016 2182 code2: 'so', 3017 2183 name: 'Somali', 3018 - android: false, 3019 - ios: false, 3020 2184 }, 3021 2185 { 3022 2186 code3: 'son', 3023 2187 code2: '', 3024 2188 name: 'Songhai languages', 3025 - android: false, 3026 - ios: false, 3027 2189 }, 3028 2190 { 3029 2191 code3: 'sot', 3030 2192 code2: 'st', 3031 2193 name: 'Southern Sotho', 3032 - android: false, 3033 - ios: false, 3034 2194 }, 3035 2195 { 3036 2196 code3: 'spa', 3037 2197 code2: 'es', 3038 2198 name: 'Spanish', 3039 - android: true, 3040 - ios: true, 3041 2199 }, 3042 2200 { 3043 2201 code3: 'sqi', 3044 2202 code2: 'sq', 3045 2203 name: 'Albanian', 3046 - android: true, 3047 - ios: false, 3048 2204 }, 3049 2205 { 3050 2206 code3: 'srd', 3051 2207 code2: 'sc', 3052 2208 name: 'Sardinian', 3053 - android: false, 3054 - ios: false, 3055 2209 }, 3056 2210 { 3057 2211 code3: 'srn', 3058 2212 code2: '', 3059 2213 name: 'Sranan Tongo', 3060 - android: false, 3061 - ios: false, 3062 2214 }, 3063 2215 { 3064 2216 code3: 'srp', 3065 2217 code2: 'sr', 3066 2218 name: 'Serbian', 3067 - android: false, 3068 - ios: false, 3069 2219 }, 3070 2220 { 3071 2221 code3: 'srr', 3072 2222 code2: '', 3073 2223 name: 'Serer', 3074 - android: false, 3075 - ios: false, 3076 2224 }, 3077 2225 { 3078 2226 code3: 'ssa', 3079 2227 code2: '', 3080 2228 name: 'Nilo-Saharan languages', 3081 - android: false, 3082 - ios: false, 3083 2229 }, 3084 2230 { 3085 2231 code3: 'ssw', 3086 2232 code2: 'ss', 3087 2233 name: 'Swati', 3088 - android: false, 3089 - ios: false, 3090 2234 }, 3091 2235 { 3092 2236 code3: 'suk', 3093 2237 code2: '', 3094 2238 name: 'Sukuma', 3095 - android: false, 3096 - ios: false, 3097 2239 }, 3098 2240 { 3099 2241 code3: 'sun', 3100 2242 code2: 'su', 3101 2243 name: 'Sundanese', 3102 - android: false, 3103 - ios: false, 3104 2244 }, 3105 2245 { 3106 2246 code3: 'sus', 3107 2247 code2: '', 3108 2248 name: 'Susu', 3109 - android: false, 3110 - ios: false, 3111 2249 }, 3112 2250 { 3113 2251 code3: 'sux', 3114 2252 code2: '', 3115 2253 name: 'Sumerian', 3116 - android: false, 3117 - ios: false, 3118 2254 }, 3119 2255 { 3120 2256 code3: 'swa', 3121 2257 code2: 'sw', 3122 2258 name: 'Swahili', 3123 - android: true, 3124 - ios: false, 3125 2259 }, 3126 2260 { 3127 2261 code3: 'swe', 3128 2262 code2: 'sv', 3129 2263 name: 'Swedish', 3130 - android: true, 3131 - ios: false, 3132 2264 }, 3133 2265 { 3134 2266 code3: 'syc', 3135 2267 code2: '', 3136 2268 name: 'Classical Syriac', 3137 - android: false, 3138 - ios: false, 3139 2269 }, 3140 2270 { 3141 2271 code3: 'syr', 3142 2272 code2: '', 3143 2273 name: 'Syriac', 3144 - android: false, 3145 - ios: false, 3146 2274 }, 3147 2275 { 3148 2276 code3: 'tah', 3149 2277 code2: 'ty', 3150 2278 name: 'Tahitian', 3151 - android: false, 3152 - ios: false, 3153 2279 }, 3154 2280 { 3155 2281 code3: 'tai', 3156 2282 code2: '', 3157 2283 name: 'Tai languages', 3158 - android: false, 3159 - ios: false, 3160 2284 }, 3161 2285 { 3162 2286 code3: 'tam', 3163 2287 code2: 'ta', 3164 2288 name: 'Tamil', 3165 - android: true, 3166 - ios: false, 3167 2289 }, 3168 2290 { 3169 2291 code3: 'tat', 3170 2292 code2: 'tt', 3171 2293 name: 'Tatar', 3172 - android: false, 3173 - ios: false, 3174 2294 }, 3175 2295 { 3176 2296 code3: 'tel', 3177 2297 code2: 'te', 3178 2298 name: 'Telugu', 3179 - android: true, 3180 - ios: false, 3181 2299 }, 3182 2300 { 3183 2301 code3: 'tem', 3184 2302 code2: '', 3185 2303 name: 'Timne', 3186 - android: false, 3187 - ios: false, 3188 2304 }, 3189 2305 { 3190 2306 code3: 'ter', 3191 2307 code2: '', 3192 2308 name: 'Tereno', 3193 - android: false, 3194 - ios: false, 3195 2309 }, 3196 2310 { 3197 2311 code3: 'tet', 3198 2312 code2: '', 3199 2313 name: 'Tetum', 3200 - android: false, 3201 - ios: false, 3202 2314 }, 3203 2315 { 3204 2316 code3: 'tgk', 3205 2317 code2: 'tg', 3206 2318 name: 'Tajik', 3207 - android: false, 3208 - ios: false, 3209 2319 }, 3210 2320 { 3211 2321 code3: 'tgl', 3212 2322 code2: 'tl', 3213 2323 name: 'Filipino', 3214 - android: true, 3215 - ios: false, 3216 2324 }, 3217 2325 { 3218 2326 code3: 'tha', 3219 2327 code2: 'th', 3220 2328 name: 'Thai', 3221 - android: true, 3222 - ios: true, 3223 2329 }, 3224 2330 { 3225 2331 code3: 'tib', 3226 2332 code2: 'bo', 3227 2333 name: 'Tibetan', 3228 - android: false, 3229 - ios: false, 3230 2334 }, 3231 2335 { 3232 2336 code3: 'tig', 3233 2337 code2: '', 3234 2338 name: 'Tigre', 3235 - android: false, 3236 - ios: false, 3237 2339 }, 3238 2340 { 3239 2341 code3: 'tir', 3240 2342 code2: 'ti', 3241 2343 name: 'Tigrinya', 3242 - android: false, 3243 - ios: false, 3244 2344 }, 3245 2345 { 3246 2346 code3: 'tiv', 3247 2347 code2: '', 3248 2348 name: 'Tiv', 3249 - android: false, 3250 - ios: false, 3251 2349 }, 3252 2350 { 3253 2351 code3: 'tkl', 3254 2352 code2: '', 3255 2353 name: 'Tokelau', 3256 - android: false, 3257 - ios: false, 3258 2354 }, 3259 2355 { 3260 2356 code3: 'tlh', 3261 2357 code2: '', 3262 2358 name: 'Klingon; tlhIngan-Hol', 3263 - android: false, 3264 - ios: false, 3265 2359 }, 3266 2360 { 3267 2361 code3: 'tli', 3268 2362 code2: '', 3269 2363 name: 'Tlingit', 3270 - android: false, 3271 - ios: false, 3272 2364 }, 3273 2365 { 3274 2366 code3: 'tmh', 3275 2367 code2: '', 3276 2368 name: 'Tamashek', 3277 - android: false, 3278 - ios: false, 3279 2369 }, 3280 2370 { 3281 2371 code3: 'tog', 3282 2372 code2: '', 3283 2373 name: 'Tonga (Nyasa)', 3284 - android: false, 3285 - ios: false, 3286 2374 }, 3287 2375 { 3288 2376 code3: 'ton', 3289 2377 code2: 'to', 3290 2378 name: 'Tongan', 3291 - android: false, 3292 - ios: false, 3293 2379 }, 3294 2380 { 3295 2381 code3: 'tpi', 3296 2382 code2: '', 3297 2383 name: 'Tok Pisin', 3298 - android: false, 3299 - ios: false, 3300 2384 }, 3301 2385 { 3302 2386 code3: 'tsi', 3303 2387 code2: '', 3304 2388 name: 'Tsimshian', 3305 - android: false, 3306 - ios: false, 3307 2389 }, 3308 2390 { 3309 2391 code3: 'tsn', 3310 2392 code2: 'tn', 3311 2393 name: 'Tswana', 3312 - android: false, 3313 - ios: false, 3314 2394 }, 3315 2395 { 3316 2396 code3: 'tso', 3317 2397 code2: 'ts', 3318 2398 name: 'Tsonga', 3319 - android: false, 3320 - ios: false, 3321 2399 }, 3322 2400 { 3323 2401 code3: 'tuk', 3324 2402 code2: 'tk', 3325 2403 name: 'Turkmen', 3326 - android: false, 3327 - ios: false, 3328 2404 }, 3329 2405 { 3330 2406 code3: 'tum', 3331 2407 code2: '', 3332 2408 name: 'Tumbuka', 3333 - android: false, 3334 - ios: false, 3335 2409 }, 3336 2410 { 3337 2411 code3: 'tup', 3338 2412 code2: '', 3339 2413 name: 'Tupi languages', 3340 - android: false, 3341 - ios: false, 3342 2414 }, 3343 2415 { 3344 2416 code3: 'tur', 3345 2417 code2: 'tr', 3346 2418 name: 'Turkish', 3347 - android: true, 3348 - ios: true, 3349 2419 }, 3350 2420 { 3351 2421 code3: 'tut', 3352 2422 code2: '', 3353 2423 name: 'Altaic languages', 3354 - android: false, 3355 - ios: false, 3356 2424 }, 3357 2425 { 3358 2426 code3: 'tvl', 3359 2427 code2: '', 3360 2428 name: 'Tuvalu', 3361 - android: false, 3362 - ios: false, 3363 2429 }, 3364 2430 { 3365 2431 code3: 'twi', 3366 2432 code2: 'tw', 3367 2433 name: 'Akan', 3368 - android: false, 3369 - ios: false, 3370 2434 }, 3371 2435 { 3372 2436 code3: 'tyv', 3373 2437 code2: '', 3374 2438 name: 'Tuvinian', 3375 - android: false, 3376 - ios: false, 3377 2439 }, 3378 2440 { 3379 2441 code3: 'udm', 3380 2442 code2: '', 3381 2443 name: 'Udmurt', 3382 - android: false, 3383 - ios: false, 3384 2444 }, 3385 2445 { 3386 2446 code3: 'uga', 3387 2447 code2: '', 3388 2448 name: 'Ugaritic', 3389 - android: false, 3390 - ios: false, 3391 2449 }, 3392 2450 { 3393 2451 code3: 'uig', 3394 2452 code2: 'ug', 3395 2453 name: 'Uyghur', 3396 - android: false, 3397 - ios: false, 3398 2454 }, 3399 2455 { 3400 2456 code3: 'ukr', 3401 2457 code2: 'uk', 3402 2458 name: 'Ukrainian', 3403 - android: true, 3404 - ios: true, 3405 2459 }, 3406 2460 { 3407 2461 code3: 'umb', 3408 2462 code2: '', 3409 2463 name: 'Umbundu', 3410 - android: false, 3411 - ios: false, 3412 2464 }, 3413 2465 { 3414 2466 code3: 'und', 3415 2467 code2: '', 3416 2468 name: 'Undetermined', 3417 - android: false, 3418 - ios: false, 3419 2469 }, 3420 2470 { 3421 2471 code3: 'urd', 3422 2472 code2: 'ur', 3423 2473 name: 'Urdu', 3424 - android: true, 3425 - ios: false, 3426 2474 }, 3427 2475 { 3428 2476 code3: 'uzb', 3429 2477 code2: 'uz', 3430 2478 name: 'Uzbek', 3431 - android: false, 3432 - ios: false, 3433 2479 }, 3434 2480 { 3435 2481 code3: 'vai', 3436 2482 code2: '', 3437 2483 name: 'Vai', 3438 - android: false, 3439 - ios: false, 3440 2484 }, 3441 2485 { 3442 2486 code3: 'ven', 3443 2487 code2: 've', 3444 2488 name: 'Venda', 3445 - android: false, 3446 - ios: false, 3447 2489 }, 3448 2490 { 3449 2491 code3: 'vie', 3450 2492 code2: 'vi', 3451 2493 name: 'Vietnamese', 3452 - android: true, 3453 - ios: true, 3454 2494 }, 3455 2495 { 3456 2496 code3: 'vol', 3457 2497 code2: 'vo', 3458 2498 name: 'Volapük', 3459 - android: false, 3460 - ios: false, 3461 2499 }, 3462 2500 { 3463 2501 code3: 'vot', 3464 2502 code2: '', 3465 2503 name: 'Votic', 3466 - android: false, 3467 - ios: false, 3468 2504 }, 3469 2505 { 3470 2506 code3: 'wak', 3471 2507 code2: '', 3472 2508 name: 'Wakashan languages', 3473 - android: false, 3474 - ios: false, 3475 2509 }, 3476 2510 { 3477 2511 code3: 'wal', 3478 2512 code2: '', 3479 2513 name: 'Wolaitta; Wolaytta', 3480 - android: false, 3481 - ios: false, 3482 2514 }, 3483 2515 { 3484 2516 code3: 'war', 3485 2517 code2: '', 3486 2518 name: 'Waray', 3487 - android: false, 3488 - ios: false, 3489 2519 }, 3490 2520 { 3491 2521 code3: 'was', 3492 2522 code2: '', 3493 2523 name: 'Washo', 3494 - android: false, 3495 - ios: false, 3496 2524 }, 3497 2525 { 3498 2526 code3: 'wel', 3499 2527 code2: 'cy', 3500 2528 name: 'Welsh', 3501 - android: true, 3502 - ios: false, 3503 2529 }, 3504 2530 { 3505 2531 code3: 'wen', 3506 2532 code2: '', 3507 2533 name: 'Sorbian languages', 3508 - android: false, 3509 - ios: false, 3510 2534 }, 3511 2535 { 3512 2536 code3: 'wln', 3513 2537 code2: 'wa', 3514 2538 name: 'Walloon', 3515 - android: false, 3516 - ios: false, 3517 2539 }, 3518 2540 { 3519 2541 code3: 'wol', 3520 2542 code2: 'wo', 3521 2543 name: 'Wolof', 3522 - android: false, 3523 - ios: false, 3524 2544 }, 3525 2545 { 3526 2546 code3: 'xal', 3527 2547 code2: '', 3528 2548 name: 'Kalmyk; Oirat', 3529 - android: false, 3530 - ios: false, 3531 2549 }, 3532 2550 { 3533 2551 code3: 'xho', 3534 2552 code2: 'xh', 3535 2553 name: 'Xhosa', 3536 - android: false, 3537 - ios: false, 3538 2554 }, 3539 2555 { 3540 2556 code3: 'yao', 3541 2557 code2: '', 3542 2558 name: 'Yao', 3543 - android: false, 3544 - ios: false, 3545 2559 }, 3546 2560 { 3547 2561 code3: 'yap', 3548 2562 code2: '', 3549 2563 name: 'Yapese', 3550 - android: false, 3551 - ios: false, 3552 2564 }, 3553 2565 { 3554 2566 code3: 'yid', 3555 2567 code2: 'yi', 3556 2568 name: 'Yiddish', 3557 - android: false, 3558 - ios: false, 3559 2569 }, 3560 2570 { 3561 2571 code3: 'yor', 3562 2572 code2: 'yo', 3563 2573 name: 'Yoruba', 3564 - android: false, 3565 - ios: false, 3566 2574 }, 3567 2575 { 3568 2576 code3: 'ypk', 3569 2577 code2: '', 3570 2578 name: 'Yupik languages', 3571 - android: false, 3572 - ios: false, 3573 2579 }, 3574 2580 { 3575 2581 code3: 'zap', 3576 2582 code2: '', 3577 2583 name: 'Zapotec', 3578 - android: false, 3579 - ios: false, 3580 2584 }, 3581 2585 { 3582 2586 code3: 'zbl', 3583 2587 code2: '', 3584 2588 name: 'Blissymbols; Blissymbolics; Bliss', 3585 - android: false, 3586 - ios: false, 3587 2589 }, 3588 2590 { 3589 2591 code3: 'zen', 3590 2592 code2: '', 3591 2593 name: 'Zenaga', 3592 - android: false, 3593 - ios: false, 3594 2594 }, 3595 2595 { 3596 2596 code3: 'zgh', 3597 2597 code2: '', 3598 2598 name: 'Standard Moroccan Tamazight', 3599 - android: false, 3600 - ios: false, 3601 2599 }, 3602 2600 { 3603 2601 code3: 'zha', 3604 2602 code2: 'za', 3605 2603 name: 'Zhuang; Chuang', 3606 - android: false, 3607 - ios: false, 3608 2604 }, 3609 2605 { 3610 2606 code3: 'zho', 3611 2607 code2: 'zh', 3612 2608 name: 'Chinese', 3613 - android: true, 3614 - ios: true, 3615 2609 }, 3616 2610 { 3617 2611 code3: 'znd', 3618 2612 code2: '', 3619 2613 name: 'Zande languages', 3620 - android: false, 3621 - ios: false, 3622 2614 }, 3623 2615 { 3624 2616 code3: 'zul', 3625 2617 code2: 'zu', 3626 2618 name: 'Zulu', 3627 - android: false, 3628 - ios: false, 3629 2619 }, 3630 2620 { 3631 2621 code3: 'zun', 3632 2622 code2: '', 3633 2623 name: 'Zuni', 3634 - android: false, 3635 - ios: false, 3636 2624 }, 3637 2625 { 3638 2626 code3: 'zza', 3639 2627 code2: '', 3640 2628 name: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', 3641 - android: false, 3642 - ios: false, 3643 2629 }, 3644 2630 ] 3645 2631
+13 -118
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 1 1 import {memo, useCallback, useMemo} from 'react' 2 - import {type GestureResponderEvent, Text as RNText, View} from 'react-native' 2 + import {Text as RNText, View} from 'react-native' 3 3 import { 4 4 AppBskyFeedDefs, 5 5 AppBskyFeedPost, ··· 15 15 import {sanitizeHandle} from '#/lib/strings/handles' 16 16 import {niceDate} from '#/lib/strings/time' 17 17 import { 18 - getPostLanguage, 19 - getTranslatorLink, 20 - isPostInLanguage, 21 - } from '#/locale/helpers' 22 - import { 23 18 POST_TOMBSTONE, 24 19 type Shadow, 25 20 usePostShadow, 26 21 } from '#/state/cache/post-shadow' 27 22 import {useProfileShadow} from '#/state/cache/profile-shadow' 28 23 import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' 29 - import {useLanguagePrefs} from '#/state/preferences' 30 24 import {type ThreadItem} from '#/state/queries/usePostThread/types' 31 25 import {useSession} from '#/state/session' 32 26 import {type OnPostSuccessData} from '#/state/shell/composer' ··· 44 38 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 45 39 import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 46 40 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 47 - import {InlineLinkText, Link} from '#/components/Link' 48 - import {Loader} from '#/components/Loader' 41 + import {Link} from '#/components/Link' 49 42 import {ContentHider} from '#/components/moderation/ContentHider' 50 43 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 51 44 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 63 56 import {WhoCanReply} from '#/components/WhoCanReply' 64 57 import {useAnalytics} from '#/analytics' 65 58 import {useActorStatus} from '#/features/liveNow' 66 - import { 67 - Provider as TranslateOnDeviceProvider, 68 - useTranslateOnDevice, 69 - } from '#/translation' 70 59 import * as bsky from '#/types/bsky' 71 60 72 61 export function ThreadItemAnchor({ ··· 89 78 } 90 79 91 80 return ( 92 - <TranslateOnDeviceProvider> 93 - <ThreadItemAnchorInner 94 - // Safeguard from clobbering per-post state below: 95 - key={postShadow.uri} 96 - item={item} 97 - isRoot={isRoot} 98 - postShadow={postShadow} 99 - onPostSuccess={onPostSuccess} 100 - threadgateRecord={threadgateRecord} 101 - postSource={postSource} 102 - /> 103 - </TranslateOnDeviceProvider> 81 + <ThreadItemAnchorInner 82 + // Safeguard from clobbering per-post state below: 83 + key={postShadow.uri} 84 + item={item} 85 + isRoot={isRoot} 86 + postShadow={postShadow} 87 + onPostSuccess={onPostSuccess} 88 + threadgateRecord={threadgateRecord} 89 + postSource={postSource} 90 + /> 104 91 ) 105 92 } 106 93 ··· 420 407 shouldProxyLinks={true} 421 408 /> 422 409 ) : undefined} 423 - <TranslatedPost postText={record.text} hideLoading /> 424 - <TranslateLink post={item.value.post} /> 410 + <TranslatedPost post={post} postText={record.text} /> 425 411 {post.embed && ( 426 412 <View style={[a.py_xs]}> 427 413 <Embed ··· 556 542 </> 557 543 ) 558 544 }) 559 - 560 - function TranslateLink({ 561 - post, 562 - }: { 563 - post: Extract<ThreadItem, {type: 'threadPost'}>['value']['post'] 564 - }) { 565 - const t = useTheme() 566 - const ax = useAnalytics() 567 - const {t: l} = useLingui() 568 - const langPrefs = useLanguagePrefs() 569 - 570 - const {translate, clearTranslation, translationState} = useTranslateOnDevice() 571 - 572 - const needsTranslation = useMemo( 573 - () => 574 - Boolean( 575 - langPrefs.primaryLanguage && 576 - !isPostInLanguage(post, [langPrefs.primaryLanguage]), 577 - ), 578 - [post, langPrefs.primaryLanguage], 579 - ) 580 - 581 - const sourceLanguage = getPostLanguage(post) 582 - 583 - const onTranslatePress = useCallback( 584 - (e: GestureResponderEvent) => { 585 - e.preventDefault() 586 - void translate( 587 - post.record.text || '', 588 - langPrefs.primaryLanguage, 589 - sourceLanguage, 590 - ) 591 - 592 - if ( 593 - bsky.dangerousIsType<AppBskyFeedPost.Record>( 594 - post.record, 595 - AppBskyFeedPost.isRecord, 596 - ) 597 - ) { 598 - ax.metric('translate', { 599 - sourceLanguages: post.record.langs ?? [], 600 - targetLanguage: langPrefs.primaryLanguage, 601 - textLength: post.record.text.length, 602 - }) 603 - } 604 - 605 - return false 606 - }, 607 - [ax, sourceLanguage, translate, langPrefs, post], 608 - ) 609 - 610 - const onHideTranslation = useCallback( 611 - (e: GestureResponderEvent) => { 612 - e.preventDefault() 613 - clearTranslation() 614 - return false 615 - }, 616 - [clearTranslation], 617 - ) 618 - 619 - return ( 620 - needsTranslation && ( 621 - <View style={[a.gap_md, a.pt_md, a.align_start]}> 622 - {translationState.status === 'loading' ? ( 623 - <View style={[a.flex_row, a.align_center, a.gap_xs]}> 624 - <Loader size="xs" /> 625 - <Text style={[a.text_sm, t.atoms.text_contrast_medium]}> 626 - <Trans>Translating…</Trans> 627 - </Text> 628 - </View> 629 - ) : translationState.status === 'success' ? ( 630 - <InlineLinkText 631 - to="#" 632 - label={l`Hide translation`} 633 - style={[a.text_sm]} 634 - onPress={onHideTranslation}> 635 - <Trans>Hide translation</Trans> 636 - </InlineLinkText> 637 - ) : ( 638 - <InlineLinkText 639 - to={getTranslatorLink(post.record.text, langPrefs.primaryLanguage)} 640 - label={l`Translate`} 641 - style={[a.text_sm]} 642 - onPress={onTranslatePress}> 643 - <Trans>Translate</Trans> 644 - </InlineLinkText> 645 - )} 646 - </View> 647 - ) 648 - ) 649 - } 650 545 651 546 function ExpandedPostDetails({ 652 547 post,
+6
src/screens/PostThread/components/ThreadItemPost.tsx
··· 38 38 import {type AppModerationCause} from '#/components/Pills' 39 39 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 40 40 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 41 + import {TranslatedPost} from '#/components/Post/Translated' 41 42 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 42 43 import {RichText} from '#/components/RichText' 43 44 import * as Skele from '#/components/Skeleton' ··· 320 321 )} 321 322 </View> 322 323 ) : undefined} 324 + <TranslatedPost 325 + hideTranslateLink={true} 326 + post={post} 327 + postText={record.text} 328 + /> 323 329 {post.embed && ( 324 330 <View style={[a.pb_xs]}> 325 331 <Embed
+6
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 38 38 import {type AppModerationCause} from '#/components/Pills' 39 39 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 40 40 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 41 + import {TranslatedPost} from '#/components/Post/Translated' 41 42 import {PostControls, PostControlsSkeleton} from '#/components/PostControls' 42 43 import {RichText} from '#/components/RichText' 43 44 import * as Skele from '#/components/Skeleton' ··· 360 361 )} 361 362 </View> 362 363 ) : null} 364 + <TranslatedPost 365 + hideTranslateLink={true} 366 + post={post} 367 + postText={record.text} 368 + /> 363 369 {post.embed && ( 364 370 <View style={[a.pb_xs]}> 365 371 <Embed
+30 -38
src/screens/VideoFeed/index.tsx
··· 33 33 type ModerationDecision, 34 34 RichText as RichTextAPI, 35 35 } from '@atproto/api' 36 - import {msg} from '@lingui/core/macro' 37 - import {useLingui} from '@lingui/react' 38 - import {Trans} from '@lingui/react/macro' 36 + import {Trans, useLingui} from '@lingui/react/macro' 39 37 import { 40 38 type RouteProp, 41 39 useFocusEffect, ··· 451 449 } 452 450 onEndReached={() => { 453 451 if (hasNextPage && !isFetchingNextPage) { 454 - fetchNextPage() 452 + void fetchNextPage() 455 453 } 456 454 }} 457 455 showsVerticalScrollIndicator={false} ··· 515 513 } 516 514 } 517 515 }, [ 516 + ax, 518 517 active, 519 518 post.uri, 520 519 post.author.did, ··· 621 620 embed: AppBskyEmbedVideo.View 622 621 onPressShow: () => void 623 622 }) { 624 - const {_} = useLingui() 623 + const {t: l} = useLingui() 625 624 const hider = Hider.useHider() 626 625 const {bottom} = useSafeAreaInsets() 627 626 ··· 648 647 <Trans>Hidden by your moderation settings.</Trans> 649 648 </Text> 650 649 <Button 651 - label={_(msg`Show anyway`)} 650 + label={l`Show anyway`} 652 651 size="small" 653 652 variant="solid" 654 653 color="secondary_inverted" ··· 676 675 <Divider style={{borderColor: 'white'}} /> 677 676 <View> 678 677 <Button 679 - label={_(msg`View details`)} 678 + label={l`View details`} 680 679 onPress={() => { 681 680 hider.showInfoDialog() 682 681 }} ··· 724 723 feedContext: string | undefined 725 724 reqId: string | undefined 726 725 }) { 727 - const {_} = useLingui() 726 + const {t: l} = useLingui() 728 727 const t = useTheme() 729 728 const {openComposer} = useOpenComposer() 730 729 const {currentAccount} = useSession() ··· 811 810 <Animated.View style={[a.px_md, animatedStyle]}> 812 811 <View style={[a.w_full, a.flex_row, a.align_center, a.gap_md]}> 813 812 <Link 814 - label={_( 815 - msg`View ${sanitizeDisplayName( 816 - post.author.displayName || post.author.handle, 817 - )}'s profile`, 818 - )} 813 + label={l`View ${sanitizeDisplayName( 814 + post.author.displayName || post.author.handle, 815 + )}'s profile`} 819 816 to={{ 820 817 screen: 'Profile', 821 818 params: {name: post.author.did}, ··· 848 845 <Button 849 846 label={ 850 847 profile.viewer?.following 851 - ? _(msg`Following ${handle}`) 852 - : _(msg`Follow ${handle}`) 848 + ? l`Following ${handle}` 849 + : l`Follow ${handle}` 853 850 } 854 851 accessibilityHint={ 855 - profile.viewer?.following 856 - ? _(msg`Unfollows the user`) 857 - : '' 852 + profile.viewer?.following ? l`Unfollows the user` : '' 858 853 } 859 854 size="small" 860 855 variant="solid" ··· 862 857 style={[a.mb_xs]} 863 858 onPress={() => 864 859 profile.viewer?.following 865 - ? queueUnfollow() 866 - : queueFollow() 860 + ? void queueUnfollow() 861 + : void queueFollow() 867 862 }> 868 863 {!!profile.viewer?.following && ( 869 864 <ButtonIcon icon={CheckIcon} /> ··· 892 887 record={record} 893 888 feedContext={feedContext} 894 889 logContext="FeedItem" 890 + forceGoogleTranslate={true} 895 891 onPressReply={() => 896 892 navigation.navigate('PostThread', { 897 893 name: post.author.did, ··· 947 943 const [hasBeenExpanded, setHasBeenExpanded] = useState(false) 948 944 const [constrained, setConstrained] = useState(false) 949 945 const [contentHeight, setContentHeight] = useState(0) 950 - const {_} = useLingui() 946 + const {t: l} = useLingui() 951 947 const {screenReaderEnabled} = useA11y() 952 948 953 949 if (expanded && !hasBeenExpanded) { ··· 988 984 /> 989 985 {constrained && !screenReaderEnabled && ( 990 986 <Pressable 991 - accessibilityHint={_(msg`Expands or collapses post text`)} 992 - accessibilityLabel={expanded ? _(msg`Read less`) : _(msg`Read more`)} 987 + accessibilityHint={l`Expands or collapses post text`} 988 + accessibilityLabel={expanded ? l`Read less` : l`Read more`} 993 989 hitSlop={HITSLOP_20} 994 990 onPress={() => setExpanded(prev => !prev)} 995 991 style={[a.absolute, a.inset_0]} ··· 1049 1045 feedContext: string | undefined 1050 1046 reqId: string | undefined 1051 1047 }) { 1052 - const {_} = useLingui() 1048 + const {t: l} = useLingui() 1053 1049 const doubleTapRef = useRef<ReturnType<typeof setTimeout> | null>(null) 1054 1050 const playHaptic = useHaptics() 1055 1051 // TODO: implement viaRepost -sfn ··· 1092 1088 clearTimeout(doubleTapRef.current) 1093 1089 doubleTapRef.current = null 1094 1090 playHaptic('Light') 1095 - queueLike() 1091 + void queueLike() 1096 1092 sendInteraction({ 1097 1093 item: post.uri, 1098 1094 event: 'app.bsky.feed.defs#interactionLike', ··· 1107 1103 return ( 1108 1104 <Button 1109 1105 disabled={!player} 1110 - aria-valuetext={ 1111 - isPlaying ? _(msg`Video is playing`) : _(msg`Video is paused`) 1112 - } 1113 - label={_( 1114 - msg`Video from ${sanitizeHandle( 1115 - post.author.handle, 1116 - '@', 1117 - )}. Tap to play or pause the video`, 1118 - )} 1119 - accessibilityHint={_(msg`Double tap to like`)} 1106 + aria-valuetext={isPlaying ? l`Video is playing` : l`Video is paused`} 1107 + label={l`Video from ${sanitizeHandle( 1108 + post.author.handle, 1109 + '@', 1110 + )}. Tap to play or pause the video`} 1111 + accessibilityHint={l`Double tap to like`} 1120 1112 onPress={onPress} 1121 1113 style={[a.absolute, a.inset_0, a.z_10]}> 1122 1114 <View /> ··· 1126 1118 1127 1119 function EndMessage() { 1128 1120 const navigation = useNavigation<NavigationProp>() 1129 - const {_} = useLingui() 1121 + const {t: l} = useLingui() 1130 1122 const t = useTheme() 1131 1123 return ( 1132 1124 <View ··· 1177 1169 variant="solid" 1178 1170 color="secondary_inverted" 1179 1171 size="small" 1180 - label={_(msg`Go back`)} 1181 - accessibilityHint={_(msg`Returns to previous page`)}> 1172 + label={l`Go back`} 1173 + accessibilityHint={l`Returns to previous page`}> 1182 1174 <ButtonIcon icon={ArrowLeftIcon} /> 1183 1175 <ButtonText> 1184 1176 <Trans>Go back</Trans>
-199
src/translation/index.tsx
··· 1 - import React, { 2 - createContext, 3 - useCallback, 4 - useContext, 5 - useMemo, 6 - useState, 7 - } from 'react' 8 - import {LayoutAnimation, Platform} from 'react-native' 9 - import {getLocales} from 'expo-localization' 10 - import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' 11 - 12 - import {useOpenLink} from '#/lib/hooks/useOpenLink' 13 - import {getTranslatorLink} from '#/locale/helpers' 14 - import {logger} from '#/logger' 15 - import {useLanguagePrefs} from '#/state/preferences' 16 - import {useAnalytics} from '#/analytics' 17 - import {IS_WEB} from '#/env' 18 - 19 - type TranslationState = 20 - | {status: 'idle'} 21 - | {status: 'loading'} 22 - | { 23 - status: 'success' 24 - translatedText: string 25 - sourceLanguage: TranslationTaskResult['sourceLanguage'] 26 - targetLanguage: TranslationTaskResult['targetLanguage'] 27 - } 28 - 29 - const IDLE: TranslationState = {status: 'idle'} 30 - 31 - /** 32 - * Attempts on-device translation via @bsky.app/expo-translate-text. 33 - * Uses a lazy import to avoid crashing if the native module isn't linked into 34 - * the current build. 35 - */ 36 - async function attemptTranslation( 37 - input: string, 38 - targetLangCodeOriginal: string, 39 - sourceLangCodeOriginal?: string, // Auto-detects if not provided 40 - ): Promise<{ 41 - translatedText: string 42 - targetLanguage: TranslationTaskResult['targetLanguage'] 43 - sourceLanguage: TranslationTaskResult['sourceLanguage'] 44 - }> { 45 - // Note that Android only supports two-character language codes and will fail 46 - // on other input. 47 - // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage 48 - let targetLangCode = 49 - Platform.OS === 'android' 50 - ? targetLangCodeOriginal.split('-')[0] 51 - : targetLangCodeOriginal 52 - const sourceLangCode = 53 - Platform.OS === 'android' 54 - ? sourceLangCodeOriginal?.split('-')[0] 55 - : sourceLangCodeOriginal 56 - 57 - // Special cases for regional languages 58 - if (Platform.OS !== 'android') { 59 - const deviceLocales = getLocales() 60 - const primaryLanguageTag = deviceLocales[0]?.languageTag 61 - switch (targetLangCodeOriginal) { 62 - case 'en': // en-US, en-GB 63 - case 'es': // es-419, es-ES 64 - case 'pt': // pt-BR, pt-PT 65 - case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW 66 - targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal 67 - break 68 - } 69 - } 70 - 71 - const {onTranslateTask} = 72 - // Needed in order to type check the dynamically imported module. 73 - // eslint-disable-next-line @typescript-eslint/consistent-type-imports 74 - require('@bsky.app/expo-translate-text') as typeof import('@bsky.app/expo-translate-text') 75 - const result = await onTranslateTask({ 76 - input, 77 - targetLangCode, 78 - sourceLangCode, 79 - }) 80 - 81 - // Since `input` is always a string, the result should always be a string. 82 - return { 83 - translatedText: 84 - typeof result.translatedTexts === 'string' ? result.translatedTexts : '', 85 - targetLanguage: result.targetLanguage, 86 - sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // iOS doesn't return the source language 87 - } 88 - } 89 - 90 - const Context = createContext<{ 91 - translationState: TranslationState 92 - translate: ( 93 - text: string, 94 - targetLangCode: string, 95 - sourceLangCode?: string, 96 - ) => Promise<void> 97 - clearTranslation: () => void 98 - }>({ 99 - translationState: IDLE, 100 - translate: async () => {}, 101 - clearTranslation: () => {}, 102 - }) 103 - Context.displayName = 'TranslationContext' 104 - 105 - /** 106 - * Native translation hook. Attempts on-device translation using Apple 107 - * Translation (iOS 18+) or Google ML Kit (Android). 108 - * 109 - * Falls back to Google Translate URL if the language pack is unavailable. 110 - * 111 - * Web uses index.web.ts which always opens Google Translate. 112 - */ 113 - export function useTranslateOnDevice() { 114 - const context = useContext(Context) 115 - if (!context) { 116 - throw new Error( 117 - 'useTranslateOnDevice must be used within a TranslateOnDeviceProvider', 118 - ) 119 - } 120 - return context 121 - } 122 - 123 - export function Provider({children}: React.PropsWithChildren<unknown>) { 124 - const [translationState, setTranslationState] = 125 - useState<TranslationState>(IDLE) 126 - const openLink = useOpenLink() 127 - const ax = useAnalytics() 128 - const {primaryLanguage} = useLanguagePrefs() 129 - 130 - const clearTranslation = useCallback(() => { 131 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 132 - setTranslationState(IDLE) 133 - }, []) 134 - 135 - const translate = useCallback( 136 - async ( 137 - text: string, 138 - targetLangCode: string = primaryLanguage, 139 - sourceLangCode?: string, 140 - ) => { 141 - setTranslationState({status: 'loading'}) 142 - try { 143 - const result = await attemptTranslation( 144 - text, 145 - targetLangCode, 146 - sourceLangCode, 147 - ) 148 - ax.metric('translate:result', { 149 - method: 'on-device', 150 - os: Platform.OS, 151 - sourceLanguage: result.sourceLanguage, 152 - targetLanguage: result.targetLanguage, 153 - }) 154 - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 155 - setTranslationState({ 156 - status: 'success', 157 - translatedText: result.translatedText, 158 - sourceLanguage: result.sourceLanguage, 159 - targetLanguage: result.targetLanguage, 160 - }) 161 - } catch (e) { 162 - if (IS_WEB) { 163 - // Web always opens Google Translate. 164 - ax.metric('translate:result', { 165 - method: 'google-translate', 166 - os: Platform.OS, 167 - sourceLanguage: sourceLangCode ?? null, 168 - targetLanguage: targetLangCode, 169 - }) 170 - } else { 171 - logger.error('Failed to translate post on device', {safeMessage: e}) 172 - // On-device translation failed (language pack missing or user dismissed 173 - // the download prompt). Fall back to Google Translate. 174 - ax.metric('translate:result', { 175 - method: 'fallback-alert', 176 - os: Platform.OS, 177 - sourceLanguage: sourceLangCode ?? null, 178 - targetLanguage: targetLangCode, 179 - }) 180 - } 181 - setTranslationState({status: 'idle'}) 182 - const translateUrl = getTranslatorLink( 183 - text, 184 - targetLangCode, 185 - sourceLangCode, 186 - ) 187 - await openLink(translateUrl) 188 - } 189 - }, 190 - [ax, openLink, primaryLanguage, setTranslationState], 191 - ) 192 - 193 - const ctx = useMemo( 194 - () => ({clearTranslation, translate, translationState}), 195 - [clearTranslation, translate, translationState], 196 - ) 197 - 198 - return <Context.Provider value={ctx}>{children}</Context.Provider> 199 - }
+7
src/view/com/post/Post.tsx
··· 33 33 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 34 34 import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 35 35 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 36 + import {TranslatedPost} from '#/components/Post/Translated' 36 37 import {PostControls} from '#/components/PostControls' 37 38 import {RichText} from '#/components/RichText' 38 39 import {SubtleHover} from '#/components/SubtleHover' ··· 152 153 }, [queryClient, post.author, outerOnBeforePress]) 153 154 154 155 const [hover, setHover] = useState(false) 156 + 155 157 return ( 156 158 <Link 157 159 href={itemHref} ··· 217 219 )} 218 220 </View> 219 221 ) : undefined} 222 + <TranslatedPost 223 + hideTranslateLink={true} 224 + post={post} 225 + postText={record.text} 226 + /> 220 227 {post.embed ? ( 221 228 <Embed 222 229 embed={post.embed}
+16
src/view/com/posts/PostFeedItem.tsx
··· 42 42 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 43 43 import {PostRepliedTo} from '#/components/Post/PostRepliedTo' 44 44 import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' 45 + import {TranslatedPost} from '#/components/Post/Translated' 45 46 import {PostControls} from '#/components/PostControls' 46 47 import {DiscoverDebug} from '#/components/PostControls/DiscoverDebug' 47 48 import {RichText} from '#/components/RichText' ··· 450 451 : [] 451 452 }, [post, currentAccount?.did, threadgateHiddenReplies]) 452 453 454 + const record = useMemo<AppBskyFeedPost.Record | undefined>( 455 + () => 456 + bsky.validate(post.record, AppBskyFeedPost.validateRecord) 457 + ? post.record 458 + : undefined, 459 + [post], 460 + ) 461 + 453 462 const onPressShowMore = useCallback(() => { 454 463 setLimitLines(false) 455 464 }, [setLimitLines]) ··· 481 490 )} 482 491 </View> 483 492 ) : undefined} 493 + {record && ( 494 + <TranslatedPost 495 + hideTranslateLink={true} 496 + post={post} 497 + postText={record.text} 498 + /> 499 + )} 484 500 {postEmbed ? ( 485 501 <View style={[a.pb_xs]}> 486 502 <Embed
+4 -4
yarn.lock
··· 3791 3791 resolved "https://registry.yarnpkg.com/@bsky.app/expo-image-crop-tool/-/expo-image-crop-tool-0.5.0.tgz#4308fbde5c15e6be9122601797bc3d9549c95e31" 3792 3792 integrity sha512-gmhQr2HWTRFyPO00fn5OmtiEVtikXusHMrN5Zoq26pu1VZX3zVE+aoc668etTqrvsQcm2Qu8fo96k5F3Wu+6wg== 3793 3793 3794 - "@bsky.app/expo-translate-text@^0.2.4": 3795 - version "0.2.4" 3796 - resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.4.tgz#6e7f20f286111ee4d550c0c84f57393fc215a675" 3797 - integrity sha512-7mvFggNfkJEufI5A3WnjfjdN3H9P6Dpx7CpDkA9npWqA8Cb2icXq3k3nz3MaXGrVKTYiJnytAffYtos7mPoeOg== 3794 + "@bsky.app/expo-translate-text@^0.2.7": 3795 + version "0.2.7" 3796 + resolved "https://registry.yarnpkg.com/@bsky.app/expo-translate-text/-/expo-translate-text-0.2.7.tgz#e34811d0f0300f8808762e5676aa50790ca5d5e8" 3797 + integrity sha512-J9zctP9hLxX0eustTKk5CBnCkk6cEdlu1s7GzUnpT65qkCSNbYqbbUCpcU2Z2S2dN/1+w6L/iHb+vmCEbZMOaQ== 3798 3798 3799 3799 "@bsky.app/react-native-mmkv@2.12.5": 3800 3800 version "2.12.5"