From 73b096e4437c6093c9836fbee2b16c783fbbe048 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 24 Feb 2026 06:34:52 +0000 Subject: [PATCH 01/43] iOS 26 (#9047) Co-authored-by: Eric Bailey Co-authored-by: Claude Opus 4.6 --- __mocks__/@notifee/react-native.ts | 6 ------ .../@react-native-camera-roll/camera-roll.js | 9 --------- __mocks__/react-native-background-fetch.ts | 4 ---- __mocks__/react-native-fs.js | 1 - __mocks__/rn-fetch-blob.js | 10 ---------- __mocks__/zeego/dropdown-menu.js | 2 -- app.config.js | 1 - .../bottom-sheet/ios/SheetViewController.swift | 15 ++++++++++++++- .../src/BottomSheetNativeComponent.tsx | 5 +++-- package.json | 7 +++++++ src/alf/atoms.ts | 4 ++++ src/components/Dialog/index.tsx | 18 +++++++++++------- src/components/Dialog/shared.tsx | 11 ++++++++--- src/components/Dialog/sheet-wrapper.ts | 4 ++-- src/components/Menu/index.tsx | 6 ++++-- .../dialogs/LanguageSelectDialog.tsx | 6 ++++-- src/env/index.ts | 8 ++++++++ src/env/index.web.ts | 1 + src/screens/SignupQueued.tsx | 6 ++++-- src/view/com/composer/Composer.tsx | 18 ++++++++---------- src/view/com/composer/GifAltText.tsx | 6 ++++-- src/view/shell/Composer.ios.tsx | 8 +++++--- src/view/shell/index.tsx | 5 +++-- 23 files changed, 90 insertions(+), 71 deletions(-) delete mode 100644 __mocks__/@notifee/react-native.ts delete mode 100644 __mocks__/@react-native-camera-roll/camera-roll.js delete mode 100644 __mocks__/react-native-background-fetch.ts delete mode 100644 __mocks__/react-native-fs.js delete mode 100644 __mocks__/rn-fetch-blob.js delete mode 100644 __mocks__/zeego/dropdown-menu.js diff --git a/__mocks__/@notifee/react-native.ts b/__mocks__/@notifee/react-native.ts deleted file mode 100644 index 7e5ccec93..000000000 --- a/__mocks__/@notifee/react-native.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default { - requestPermission: jest.fn(), - onForegroundEvent: jest.fn(), - setBadgeCount: jest.fn(), - displayNotification: jest.fn(), -} diff --git a/__mocks__/@react-native-camera-roll/camera-roll.js b/__mocks__/@react-native-camera-roll/camera-roll.js deleted file mode 100644 index 8f1aea43b..000000000 --- a/__mocks__/@react-native-camera-roll/camera-roll.js +++ /dev/null @@ -1,9 +0,0 @@ -export const CameraRoll = { - getPhotos: jest.fn().mockResolvedValue({ - edges: [ - {node: {image: {uri: 'path/to/image1.jpg'}}}, - {node: {image: {uri: 'path/to/image2.jpg'}}}, - {node: {image: {uri: 'path/to/image3.jpg'}}}, - ], - }), -} diff --git a/__mocks__/react-native-background-fetch.ts b/__mocks__/react-native-background-fetch.ts deleted file mode 100644 index 0cb644c4d..000000000 --- a/__mocks__/react-native-background-fetch.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - configure: jest.fn().mockResolvedValue(0), - finish: jest.fn(), -} diff --git a/__mocks__/react-native-fs.js b/__mocks__/react-native-fs.js deleted file mode 100644 index b1c6ea436..000000000 --- a/__mocks__/react-native-fs.js +++ /dev/null @@ -1 +0,0 @@ -export default {} diff --git a/__mocks__/rn-fetch-blob.js b/__mocks__/rn-fetch-blob.js deleted file mode 100644 index dedfbdf89..000000000 --- a/__mocks__/rn-fetch-blob.js +++ /dev/null @@ -1,10 +0,0 @@ -jest.mock('rn-fetch-blob', () => { - return { - __esModule: true, - default: { - fs: { - unlink: jest.fn(), - }, - }, - } -}) diff --git a/__mocks__/zeego/dropdown-menu.js b/__mocks__/zeego/dropdown-menu.js deleted file mode 100644 index 1d51addca..000000000 --- a/__mocks__/zeego/dropdown-menu.js +++ /dev/null @@ -1,2 +0,0 @@ -export const DropdownMenu = jest.fn().mockImplementation(() => {}) -export const create = jest.fn().mockImplementation(() => {}) diff --git a/app.config.js b/app.config.js index db9a37165..0ed73cf5a 100644 --- a/app.config.js +++ b/app.config.js @@ -112,7 +112,6 @@ module.exports = function (_config) { 'zh-Hans', 'zh-Hant', ], - UIDesignRequiresCompatibility: true, }, associatedDomains: ASSOCIATED_DOMAINS, entitlements: { diff --git a/modules/bottom-sheet/ios/SheetViewController.swift b/modules/bottom-sheet/ios/SheetViewController.swift index 90d0fed0d..eaf0a7123 100644 --- a/modules/bottom-sheet/ios/SheetViewController.swift +++ b/modules/bottom-sheet/ios/SheetViewController.swift @@ -27,6 +27,19 @@ class SheetViewController: UIViewController { return } + // On iOS 26, the floaty sheet presentation adds the device bottom safe area + // on top of the custom detent value, creating visible padding inside the pill. + // Subtract it so the pill height matches our actual content. + var bottomSafeAreaAdjustment: CGFloat = 0 + if #available(iOS 26.0, *) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + bottomSafeAreaAdjustment = window.safeAreaInsets.bottom + } + } + + let adjustedHeight = contentHeight - bottomSafeAreaAdjustment + if #available(iOS 16.0, *) { if contentHeight > screenHeight - 100 { sheet.detents = [ @@ -36,7 +49,7 @@ class SheetViewController: UIViewController { } else { sheet.detents = [ .custom { _ in - return contentHeight + return adjustedHeight } ] if !preventExpansion { diff --git a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx index 817d476fb..0fa4c8aa2 100644 --- a/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx +++ b/modules/bottom-sheet/src/BottomSheetNativeComponent.tsx @@ -5,6 +5,7 @@ import { type NativeSyntheticEvent, Platform, type StyleProp, + useWindowDimensions, View, type ViewStyle, } from 'react-native' @@ -21,8 +22,6 @@ import { Context as PortalContext, } from './BottomSheetPortal' -const screenHeight = Dimensions.get('screen').height - const NativeView: React.ComponentType< BottomSheetViewProps & { ref: React.RefObject @@ -94,6 +93,7 @@ export class BottomSheetNativeComponent extends React.Component< let extraStyles if (IS_IOS15 && this.state.viewHeight) { + const screenHeight = Dimensions.get('screen').height const {viewHeight} = this.state const cornerRadius = this.props.cornerRadius ?? 0 if (viewHeight < screenHeight / 2) { @@ -154,6 +154,7 @@ function BottomSheetNativeComponentInner({ }) { const insets = useSafeAreaInsets() const cornerRadius = rest.cornerRadius ?? 0 + const {height: screenHeight} = useWindowDimensions() const sheetHeight = IS_IOS ? screenHeight - insets.top : screenHeight diff --git a/package.json b/package.json index 9f3250050..cc4305af8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,13 @@ "expo-image-picker" ] } + }, + "install": { + "exclude": [ + "react-native-reanimated", + "@sentry/react-native", + "react-native-pager-view" + ] } }, "scripts": { diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts index ffac94a5d..da9b99720 100644 --- a/src/alf/atoms.ts +++ b/src/alf/atoms.ts @@ -10,6 +10,10 @@ const EXP_CURVE = 'cubic-bezier(0.16, 1, 0.3, 1)' export const atoms = { ...baseAtoms, + rounded_sheet: { + borderRadius: 40, + }, + h_full_vh: web({ height: '100vh', }), diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 1c2cad9e6..cdc3d657b 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -38,7 +38,7 @@ import { type DialogOuterProps, } from '#/components/Dialog/types' import {createInput} from '#/components/forms/TextField' -import {IS_ANDROID, IS_IOS} from '#/env' +import {IS_ANDROID, IS_IOS, IS_LIQUID_GLASS} from '#/env' import {BottomSheet, BottomSheetSnapPoint} from '../../../modules/bottom-sheet' import { type BottomSheetSnapPointChangeEvent, @@ -166,7 +166,8 @@ export function Outer({ return ( {children} @@ -253,7 +257,7 @@ export const ScrollableInner = React.forwardRef( {renderLeft && ( - {renderLeft()} + + {renderLeft()} + )} {children} {renderRight && ( - {renderRight()} + + {renderRight()} + )} ) diff --git a/src/components/Dialog/sheet-wrapper.ts b/src/components/Dialog/sheet-wrapper.ts index d6ff68785..558851438 100644 --- a/src/components/Dialog/sheet-wrapper.ts +++ b/src/components/Dialog/sheet-wrapper.ts @@ -1,7 +1,7 @@ import {useCallback} from 'react' import {SystemBars} from 'react-native-edge-to-edge' -import {IS_IOS} from '#/env' +import {IS_IOS, IS_LIQUID_GLASS} from '#/env' /** * If we're calling a system API like the image picker that opens a sheet @@ -9,7 +9,7 @@ import {IS_IOS} from '#/env' */ export function useSheetWrapper() { return useCallback(async (promise: Promise): Promise => { - if (IS_IOS) { + if (IS_IOS && !IS_LIQUID_GLASS) { const entry = SystemBars.pushStackEntry({ style: { statusBar: 'light', diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx index 848095671..65d279e19 100644 --- a/src/components/Menu/index.tsx +++ b/src/components/Menu/index.tsx @@ -273,7 +273,8 @@ export function ContainerItem({ a.align_center, a.gap_sm, a.px_md, - a.rounded_md, + a.rounded_lg, + a.curve_continuous, a.border, t.atoms.bg_contrast_25, t.atoms.border_contrast_low, @@ -311,7 +312,8 @@ export function Group({children, style}: GroupProps) { return ( + nativeOptions={{ + minHeight: IS_LIQUID_GLASS ? height : height - insets.top, + }}> = 26 diff --git a/src/env/index.web.ts b/src/env/index.web.ts index 0435569ed..0a078fdeb 100644 --- a/src/env/index.web.ts +++ b/src/env/index.web.ts @@ -47,3 +47,4 @@ export const IS_WEB_FIREFOX: boolean = /firefox|fxios/i.test( export const IS_HIGH_DPI: boolean = window.matchMedia( '(min-resolution: 2dppx)', ).matches +export const IS_LIQUID_GLASS: boolean = false diff --git a/src/screens/SignupQueued.tsx b/src/screens/SignupQueued.tsx index 2daa701b0..55878700b 100644 --- a/src/screens/SignupQueued.tsx +++ b/src/screens/SignupQueued.tsx @@ -14,7 +14,7 @@ import {atoms as a, native, useBreakpoints, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Loader} from '#/components/Loader' import {P, Text} from '#/components/Typography' -import {IS_IOS, IS_WEB} from '#/env' +import {IS_IOS, IS_LIQUID_GLASS, IS_WEB} from '#/env' const COL_WIDTH = 400 @@ -107,7 +107,9 @@ export function SignupQueued() { animationType={native('slide')} presentationStyle="formSheet" style={[web(a.util_screen_outer)]}> - {IS_IOS && } + {IS_IOS && !IS_LIQUID_GLASS && ( + + )} - + -- 2.51.2 From 95bede2dcb483f263bd0b894c31557ab512f47a4 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Wed, 25 Feb 2026 03:09:49 +0000 Subject: [PATCH 07/43] Nightly source-language update --- src/locale/locales/en/messages.po | 86 +++++++++++++++---------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index ff59994d0..161ceb706 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -868,7 +868,7 @@ msgstr "" msgid "Add another post" msgstr "" -#: src/view/com/composer/Composer.tsx:1977 +#: src/view/com/composer/Composer.tsx:1983 msgid "Add another post to thread" msgstr "" @@ -1926,8 +1926,8 @@ msgstr "" #: src/screens/Settings/Settings.tsx:306 #: src/screens/Takendown.tsx:102 #: src/screens/Takendown.tsx:105 -#: src/view/com/composer/Composer.tsx:1527 -#: src/view/com/composer/Composer.tsx:1539 +#: src/view/com/composer/Composer.tsx:1533 +#: src/view/com/composer/Composer.tsx:1545 #: src/view/com/composer/photos/EditImageDialog.web.tsx:44 #: src/view/com/composer/photos/EditImageDialog.web.tsx:53 #: src/view/shell/desktop/LeftNav.tsx:214 @@ -2331,7 +2331,7 @@ msgstr "" msgid "Closes password update alert" msgstr "" -#: src/view/com/composer/Composer.tsx:1536 +#: src/view/com/composer/Composer.tsx:1542 msgid "Closes post composer and discards post draft" msgstr "" @@ -2397,11 +2397,11 @@ msgstr "" msgid "Compose reply" msgstr "" -#: src/view/com/composer/Composer.tsx:2373 +#: src/view/com/composer/Composer.tsx:2379 msgid "Compressing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2375 +#: src/view/com/composer/Composer.tsx:2381 msgid "Compressing video..." msgstr "" @@ -3121,6 +3121,10 @@ msgstr "" msgid "Disable 2FA" msgstr "" +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 +msgid "Disable captions" +msgstr "Disable captions" + #: src/components/dialogs/EmailDialog/screens/Manage2FA/Disable.tsx:158 msgid "Disable email 2FA" msgstr "" @@ -3142,10 +3146,6 @@ msgstr "" msgid "Disable replies entirely" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:405 -msgid "Disable subtitles" -msgstr "" - #: src/lib/moderation/useLabelBehaviorDescription.ts:35 #: src/lib/moderation/useLabelBehaviorDescription.ts:45 #: src/lib/moderation/useLabelBehaviorDescription.ts:71 @@ -3212,7 +3212,7 @@ msgstr "" msgid "Dismiss banner" msgstr "" -#: src/view/com/composer/Composer.tsx:2294 +#: src/view/com/composer/Composer.tsx:2300 msgid "Dismiss error" msgstr "" @@ -3584,6 +3584,10 @@ msgstr "" msgid "Enable adult content" msgstr "" +#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406 +msgid "Enable captions" +msgstr "Enable captions" + #: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:93 msgid "Enable email 2FA" msgstr "" @@ -3611,10 +3615,6 @@ msgstr "" msgid "Enable quote posts of this post" msgstr "" -#: src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx:406 -msgid "Enable subtitles" -msgstr "" - #: src/components/dialogs/EmbedConsent.tsx:90 msgid "Enable this source only" msgstr "" @@ -3640,8 +3640,8 @@ msgid "End of feed" msgstr "" #: src/view/com/composer/videos/SubtitleDialog.tsx:182 -msgid "Ensure you have selected a language for each subtitle file." -msgstr "" +msgid "Ensure you have selected a language for each caption file." +msgstr "Ensure you have selected a language for each caption file." #: src/components/contacts/screens/VerifyNumber.tsx:231 msgid "Enter 6-digit code that was sent to your phone number" @@ -3708,7 +3708,7 @@ msgstr "" msgid "Entertainment" msgstr "" -#: src/view/com/composer/Composer.tsx:2393 +#: src/view/com/composer/Composer.tsx:2399 #: src/view/com/util/error/ErrorScreen.tsx:43 msgid "Error" msgstr "" @@ -4642,7 +4642,7 @@ msgstr "" msgid "GIF" msgstr "" -#: src/view/com/composer/Composer.tsx:2398 +#: src/view/com/composer/Composer.tsx:2404 msgid "GIF uploaded" msgstr "" @@ -5379,7 +5379,7 @@ msgid "It's just you right now! Add more people to your starter pack by searchin msgstr "" #. placeholder {0}: videoState.jobId -#: src/view/com/composer/Composer.tsx:2313 +#: src/view/com/composer/Composer.tsx:2319 msgid "Job ID: {0}" msgstr "" @@ -6869,7 +6869,7 @@ msgid "Open drawer menu" msgstr "" #: src/screens/Messages/components/MessageInput.web.tsx:181 -#: src/view/com/composer/Composer.tsx:1962 +#: src/view/com/composer/Composer.tsx:1968 msgid "Open emoji picker" msgstr "" @@ -6982,7 +6982,7 @@ msgstr "" msgid "Opens device gallery to select up to {MAX_IMAGES, plural, other {# images}}, or a single video or GIF." msgstr "" -#: src/view/com/composer/Composer.tsx:1963 +#: src/view/com/composer/Composer.tsx:1969 msgid "Opens emoji picker" msgstr "" @@ -7471,7 +7471,7 @@ msgstr "" msgid "Porn" msgstr "" -#: src/view/com/composer/Composer.tsx:1611 +#: src/view/com/composer/Composer.tsx:1617 msgctxt "action" msgid "Post" msgstr "" @@ -7491,7 +7491,7 @@ msgstr "" msgid "Post a video" msgstr "" -#: src/view/com/composer/Composer.tsx:1609 +#: src/view/com/composer/Composer.tsx:1615 msgctxt "action" msgid "Post All" msgstr "" @@ -7665,11 +7665,11 @@ msgstr "" msgid "Privacy violation of a minor" msgstr "" -#: src/view/com/composer/Composer.tsx:2387 +#: src/view/com/composer/Composer.tsx:2393 msgid "Processing GIF..." msgstr "" -#: src/view/com/composer/Composer.tsx:2389 +#: src/view/com/composer/Composer.tsx:2395 msgid "Processing video..." msgstr "" @@ -7711,22 +7711,22 @@ msgid "Public, sharable lists of users to mute or block in bulk." msgstr "" #. Accessibility label for button to publish a single post -#: src/view/com/composer/Composer.tsx:1594 +#: src/view/com/composer/Composer.tsx:1600 msgid "Publish post" msgstr "" #. Accessibility label for button to publish multiple posts in a thread -#: src/view/com/composer/Composer.tsx:1587 +#: src/view/com/composer/Composer.tsx:1593 msgid "Publish posts" msgstr "" #. Accessibility label for button to publish multiple replies in a thread -#: src/view/com/composer/Composer.tsx:1572 +#: src/view/com/composer/Composer.tsx:1578 msgid "Publish replies" msgstr "" #. Accessibility label for button to publish a single reply -#: src/view/com/composer/Composer.tsx:1579 +#: src/view/com/composer/Composer.tsx:1585 msgid "Publish reply" msgstr "" @@ -7960,6 +7960,10 @@ msgstr "" msgid "Remove Banner" msgstr "" +#: src/view/com/composer/videos/SubtitleDialog.tsx:286 +msgid "Remove caption file" +msgstr "Remove caption file" + #: src/screens/Messages/components/MessageInputEmbed.tsx:212 msgid "Remove embed" msgstr "" @@ -8022,10 +8026,6 @@ msgstr "" msgid "Remove repost" msgstr "" -#: src/view/com/composer/videos/SubtitleDialog.tsx:286 -msgid "Remove subtitle file" -msgstr "" - #: src/components/contacts/screens/ViewMatches.tsx:500 #: src/screens/Settings/FindContactsSettings.tsx:328 msgid "Remove suggestion" @@ -8149,7 +8149,7 @@ msgstr "" msgid "Replies to this post are disabled." msgstr "" -#: src/view/com/composer/Composer.tsx:1607 +#: src/view/com/composer/Composer.tsx:1613 msgctxt "action" msgid "Reply" msgstr "" @@ -8806,6 +8806,11 @@ msgstr "" msgid "Select app language" msgstr "" +#: src/view/com/composer/videos/SubtitleFilePicker.tsx:61 +#: src/view/com/composer/videos/SubtitleFilePicker.tsx:68 +msgid "Select caption file (.vtt)" +msgstr "Select caption file (.vtt)" + #: src/screens/Settings/LanguageSettings.tsx:176 #: src/screens/Settings/LanguageSettings.tsx:214 msgid "Select content languages" @@ -8866,11 +8871,6 @@ msgstr "" msgid "Select primary language" msgstr "" -#: src/view/com/composer/videos/SubtitleFilePicker.tsx:61 -#: src/view/com/composer/videos/SubtitleFilePicker.tsx:68 -msgid "Select subtitle file (.vtt)" -msgstr "" - #: src/components/InternationalPhoneCodeSelect.tsx:68 msgid "Select telephone code" msgstr "" @@ -10868,7 +10868,7 @@ msgstr "" msgid "Upload from Library" msgstr "" -#: src/view/com/composer/Composer.tsx:2380 +#: src/view/com/composer/Composer.tsx:2386 msgid "Uploading GIF..." msgstr "" @@ -10881,7 +10881,7 @@ msgstr "" msgid "Uploading link thumbnail..." msgstr "" -#: src/view/com/composer/Composer.tsx:2382 +#: src/view/com/composer/Composer.tsx:2388 msgid "Uploading video..." msgstr "" @@ -11160,7 +11160,7 @@ msgstr "" msgid "Video settings" msgstr "" -#: src/view/com/composer/Composer.tsx:2400 +#: src/view/com/composer/Composer.tsx:2406 msgid "Video uploaded" msgstr "" -- 2.51.2 From 85ffc77983b80a102d4d8d5897a10e84cf899292 Mon Sep 17 00:00:00 2001 From: surfdude29 <149612116+surfdude29@users.noreply.github.com> Date: Wed, 25 Feb 2026 09:18:20 +0000 Subject: [PATCH 08/43] Show hashtag symbol for Mute option in menu (#9942) --- src/components/RichTextTag.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/components/RichTextTag.tsx b/src/components/RichTextTag.tsx index 6c9dc4415..8e1a5e5ce 100644 --- a/src/components/RichTextTag.tsx +++ b/src/components/RichTextTag.tsx @@ -148,7 +148,11 @@ export function RichTextTag({ { if (isMuted) { resetUpsert() @@ -161,7 +165,9 @@ export function RichTextTag({ } }}> - {isMuted ? _(msg`Unmute ${tag}`) : _(msg`Mute ${tag}`)} + {isMuted + ? _(msg`Unmute ${isCashtag ? tag : `#${tag}`}`) + : _(msg`Mute ${isCashtag ? tag : `#${tag}`}`)} -- 2.51.2 From 49272be36bbe492fd1c3dcc62f7e98da067d028b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 25 Feb 2026 19:43:46 +0000 Subject: [PATCH 09/43] Fix up polyfills, remove unused deps (#9946) --- docs/build.md | 2 +- eslint.config.mjs | 2 -- jest/jestSetup.js | 2 -- package.json | 7 +----- src/platform/polyfills.ts | 2 +- src/platform/polyfills.web.ts | 7 +++--- yarn.lock | 45 +---------------------------------- 7 files changed, 7 insertions(+), 60 deletions(-) diff --git a/docs/build.md b/docs/build.md index bcae2ece0..48eea5aec 100644 --- a/docs/build.md +++ b/docs/build.md @@ -164,8 +164,8 @@ See [testing.md](./testing.md). `./platform/polyfills.*.ts` adds polyfills to the environment. Currently, this includes: - TextEncoder / TextDecoder -- react-native-url-polyfill - Array#findLast (on web) +- setImmediate (on web) ### Sentry sourcemaps diff --git a/eslint.config.mjs b/eslint.config.mjs index 7a06ebdb2..18f112f0b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -23,8 +23,6 @@ export default defineConfig( { ignores: [ '**/__mocks__/*.ts', - 'src/platform/polyfills.ts', - 'src/third-party/**', 'ios/**', 'android/**', 'coverage/**', diff --git a/jest/jestSetup.js b/jest/jestSetup.js index 2b9ddf534..c839d9e53 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -1,7 +1,5 @@ /* global jest */ import 'react-native-gesture-handler/jestSetup' -// IMPORTANT: this is what's used in the native runtime -import 'react-native-url-polyfill/auto' import {configure} from '@testing-library/react-native' diff --git a/package.json b/package.json index cc4305af8..a21ca867e 100644 --- a/package.json +++ b/package.json @@ -132,9 +132,7 @@ "@types/invariant": "^2.2.37", "@types/lodash.throttle": "^4.1.9", "@types/node": "^20.14.3", - "@zxing/text-encoding": "^0.9.0", "array.prototype.findlast": "^1.2.3", - "await-lock": "^2.2.2", "babel-plugin-transform-remove-console": "^6.9.4", "bcp-47": "^2.1.0", "bcp-47-match": "^2.0.3", @@ -172,14 +170,12 @@ "expo-sms": "^14.0.7", "expo-splash-screen": "~31.0.12", "expo-system-ui": "~6.0.9", - "expo-task-manager": "~14.0.9", "expo-updates": "~29.0.14", "expo-video": "~3.0.15", "expo-video-thumbnails": "^10.0.8", "expo-web-browser": "~15.0.10", "fast-deep-equal": "^3.1.3", "fast-text-encoding": "^1.0.6", - "history": "^5.3.0", "hls.js": "^1.6.2", "idb-keyval": "^6.2.2", "js-sha256": "^0.9.0", @@ -210,7 +206,6 @@ "react-native-drawer-layout": "^4.2.1", "react-native-edge-to-edge": "^1.6.0", "react-native-gesture-handler": "~2.28.0", - "react-native-get-random-values": "~1.11.0", "react-native-keyboard-controller": "^1.20.7", "react-native-pager-view": "6.8.0", "react-native-progress": "bluesky-social/react-native-progress", @@ -220,7 +215,6 @@ "react-native-screens": "^4.19.0", "react-native-svg": "15.12.1", "react-native-uitextview": "^1.4.0", - "react-native-url-polyfill": "^1.3.0", "react-native-uuid": "^2.0.3", "react-native-view-shot": "^4.0.3", "react-native-web": "^0.21.0", @@ -229,6 +223,7 @@ "react-remove-scroll-bar": "^2.3.8", "react-responsive": "^10.0.1", "react-textarea-autosize": "^8.5.3", + "setimmediate": "^1.0.5", "sonner": "^2.0.7", "sonner-native": "^0.21.0", "tippy.js": "^6.3.7", diff --git a/src/platform/polyfills.ts b/src/platform/polyfills.ts index aba06c59f..976528131 100644 --- a/src/platform/polyfills.ts +++ b/src/platform/polyfills.ts @@ -1,3 +1,3 @@ -import 'react-native-url-polyfill/auto' import 'fast-text-encoding' + export {} diff --git a/src/platform/polyfills.web.ts b/src/platform/polyfills.web.ts index 7c5a1c00a..460ac8d04 100644 --- a/src/platform/polyfills.web.ts +++ b/src/platform/polyfills.web.ts @@ -1,8 +1,5 @@ import 'array.prototype.findlast/auto' -/// - -// @ts-ignore whatever typescript wants to complain about here, I dont care about -prf -window.setImmediate = (cb: () => void) => setTimeout(cb, 0) +import 'setimmediate' if (process.env.NODE_ENV !== 'production') { // In development, react-native-web's tries to validate that @@ -32,3 +29,5 @@ if (process.env.NODE_ENV !== 'production') { } } } + +export {} diff --git a/yarn.lock b/yarn.lock index e0214c0a9..7e1958e24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3427,7 +3427,7 @@ resolved "https://registry.yarnpkg.com/@babel/regjsgen/-/regjsgen-0.8.0.tgz#f0ba69b075e1f05fb2825b7fad991e7adbb18310" integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA== -"@babel/runtime@^7.15.4", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.15.4", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.8.4": version "7.22.10" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.22.10.tgz#ae3e9631fd947cb7e3610d3e9d8fef5f76696682" integrity sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ== @@ -8479,11 +8479,6 @@ js-yaml "^3.10.0" tslib "^2.4.0" -"@zxing/text-encoding@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b" - integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== - abab@^2.0.5, abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -11975,13 +11970,6 @@ expo-system-ui@~6.0.9: "@react-native/normalize-colors" "0.81.5" debug "^4.3.2" -expo-task-manager@~14.0.9: - version "14.0.9" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-14.0.9.tgz#7e410711cf3fd0c465a191916d699c6560c93192" - integrity sha512-GKWtXrkedr4XChHfTm5IyTcSfMtCPxzx89y4CMVqKfyfROATibrE/8UI5j7UC/pUOfFoYlQvulQEvECMreYuUA== - dependencies: - unimodules-app-loader "~6.0.8" - expo-updates-interface@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/expo-updates-interface/-/expo-updates-interface-2.0.0.tgz#7721cb64c37bcb46b23827b2717ef451a9378749" @@ -12096,11 +12084,6 @@ express@^4.17.2, express@^4.17.3, express@^4.18.2: utils-merge "1.0.1" vary "~1.1.2" -fast-base64-decode@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz#b434a0dd7d92b12b43f26819300d2dafb83ee418" - integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== - fast-deep-equal@^3, fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -12966,13 +12949,6 @@ hermes-parser@^0.25.1: dependencies: hermes-estree "0.25.1" -history@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/history/-/history-5.3.0.tgz#1548abaa245ba47992f063a0783db91ef201c73b" - integrity sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ== - dependencies: - "@babel/runtime" "^7.7.6" - hls.js@^1.6.2: version "1.6.2" resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.6.2.tgz#02272bea644b5f61f71741256618d6b629ae7834" @@ -17368,13 +17344,6 @@ react-native-gesture-handler@~2.28.0: hoist-non-react-statics "^3.3.0" invariant "^2.2.4" -react-native-get-random-values@~1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz#1ca70d1271f4b08af92958803b89dccbda78728d" - integrity sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ== - dependencies: - fast-base64-decode "^1.0.0" - react-native-is-edge-to-edge@1.1.7: version "1.1.7" resolved "https://registry.yarnpkg.com/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz#28947688f9fafd584e73a4f935ea9603bd9b1939" @@ -17456,13 +17425,6 @@ react-native-uitextview@^1.4.0: resolved "https://registry.yarnpkg.com/react-native-uitextview/-/react-native-uitextview-1.4.0.tgz#d1b583cc173cec00f4fdd03744cca76c54a12fbb" integrity sha512-itm/frzkn/ma3+lwmKn2CkBOXPNo4bL8iVwQwjlzix5gVO59T2+axdfoj/Wi+Ra6F76KzNKxSah+7Y8dYmCHbQ== -react-native-url-polyfill@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/react-native-url-polyfill/-/react-native-url-polyfill-1.3.0.tgz#c1763de0f2a8c22cc3e959b654c8790622b6ef6a" - integrity sha512-w9JfSkvpqqlix9UjDvJjm1EjSt652zVQ6iwCIj1cVVkwXf4jQhQgTNXY6EVTwuAmUjg6BC6k9RHCBynoLFo3IQ== - dependencies: - whatwg-url-without-unicode "8.0.0-3" - react-native-uuid@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/react-native-uuid/-/react-native-uuid-2.0.3.tgz#f85f8a8d68e52e3f1c18ba0f02ec7776f9d4a0da" @@ -19783,11 +19745,6 @@ unicode-segmenter@0.14.5, unicode-segmenter@^0.14.0, unicode-segmenter@^0.14.5: resolved "https://registry.yarnpkg.com/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz#c658f6dd30de172cdcd94542adc205ba43fb63c6" integrity sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g== -unimodules-app-loader@~6.0.8: - version "6.0.8" - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-6.0.8.tgz#81c868b726e24b7e37d708fe0117e1869c721cdb" - integrity sha512-fqS8QwT/MC/HAmw1NKCHdzsPA6WaLm0dNmoC5Pz6lL+cDGYeYCNdHMO9fy08aL2ZD7cVkNM0pSR/AoNRe+rslA== - unique-string@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" -- 2.51.2 From 00816b70dc263525daf26cb28f1eff56f3ca4575 Mon Sep 17 00:00:00 2001 From: Alex Benzer Date: Wed, 25 Feb 2026 11:52:10 -0800 Subject: [PATCH 10/43] [APP-1859] pinned feed drag n drop (#9893) Co-authored-by: Samuel Newman Co-authored-by: vineyardbovines Co-authored-by: Claude Opus 4.6 --- .../dotGrid2x3_stroke2_corner2_rounded.svg | 1 + src/components/DraggableList/index.tsx | 489 ++++++++++++++++++ src/components/DraggableList/index.web.tsx | 168 ++++++ .../PostControls/PostMenu/index.tsx | 2 +- src/components/dms/ActionsWrapper.web.tsx | 2 +- src/components/dms/ConvoMenu.tsx | 2 +- .../dms/EmojiReactionPicker.web.tsx | 2 +- src/components/icons/DotGrid.tsx | 6 +- .../Profile/components/ProfileFeedHeader.tsx | 2 +- .../components/MoreOptionsMenu.tsx | 2 +- src/screens/SavedFeeds.tsx | 385 +++++++++++--- src/screens/Settings/Settings.tsx | 2 +- src/screens/StarterPack/StarterPackScreen.tsx | 2 +- src/view/com/composer/drafts/DraftItem.tsx | 2 +- src/view/com/profile/ProfileMenu.tsx | 2 +- src/view/shell/desktop/LeftNav.tsx | 2 +- .../shell/desktop/SidebarTrendingTopics.tsx | 2 +- 17 files changed, 977 insertions(+), 96 deletions(-) create mode 100644 assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg create mode 100644 src/components/DraggableList/index.tsx create mode 100644 src/components/DraggableList/index.web.tsx diff --git a/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg b/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg new file mode 100644 index 000000000..7b1cb66fc --- /dev/null +++ b/assets/icons/dotGrid2x3_stroke2_corner2_rounded.svg @@ -0,0 +1 @@ + diff --git a/src/components/DraggableList/index.tsx b/src/components/DraggableList/index.tsx new file mode 100644 index 000000000..86076c20a --- /dev/null +++ b/src/components/DraggableList/index.tsx @@ -0,0 +1,489 @@ +import {useLayoutEffect, useRef} from 'react' +import {Gesture, GestureDetector} from 'react-native-gesture-handler' +import Animated, { + type AnimatedRef, + measure, + runOnJS, + scrollTo, + type SharedValue, + useAnimatedRef, + useAnimatedStyle, + useFrameCallback, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated' + +import {useHaptics} from '#/lib/haptics' +import {atoms as a, useTheme, web} from '#/alf' +import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' +import {IS_IOS} from '#/env' + +/** + * Drag-to-reorder list. Items are absolutely positioned in a fixed-height + * container and animated via Reanimated shared values on the UI thread. + * + * All positioning is driven by a `slots` map (key → index) and translateY + * (no discrete `top` changes). On drag end the new slot assignment is + * computed on the UI thread first, then React state is updated via runOnJS. + * + * See SortableList.web.tsx for the web implementation using pointer events. + */ + +interface SortableListProps { + data: T[] + keyExtractor: (item: T) => string + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onReorder: (data: T[]) => void + onDragStart?: () => void + onDragEnd?: () => void + /** Fixed row height used for position math. */ + itemHeight: number + /** Ref to the parent Animated.ScrollView for auto-scroll. */ + scrollRef?: AnimatedRef + /** Scroll offset shared value from useScrollViewOffset. */ + scrollOffset?: SharedValue +} + +const AUTO_SCROLL_THRESHOLD = 50 +const AUTO_SCROLL_SPEED = 4 + +/** + * Bundled into a single shared value so all fields update atomically + * in one set() call on the UI thread. + */ +interface DragState { + /** Maps each item key to its current slot index. */ + slots: Record + /** Key of the item being dragged, or '' when idle. */ + activeKey: string + /** Slot the active item started in. */ + dragStartSlot: number +} + +export function SortableList({ + data, + keyExtractor, + renderItem, + onReorder, + onDragStart, + onDragEnd, + itemHeight, + scrollRef, + scrollOffset, +}: SortableListProps) { + const t = useTheme() + const state = useSharedValue({ + slots: Object.fromEntries(data.map((item, i) => [keyExtractor(item), i])), + activeKey: '', + dragStartSlot: -1, + }) + const dragY = useSharedValue(0) + + // Auto-scroll shared values + const scrollCompensation = useSharedValue(0) + const isGestureActive = useSharedValue(false) + // We track scroll position ourselves because scrollOffset.get() lags + // by one frame after scrollTo(), causing a feedback loop where the + // frame callback keeps thinking the item is at the edge. + const trackedScrollY = useSharedValue(0) + + // For measuring list position within scroll content + const listRef = useAnimatedRef() + const listContentOffset = useSharedValue(0) + const viewportHeight = useSharedValue(0) + const measureDone = useSharedValue(false) + + // Sync slots when data changes externally (e.g. pin/unpin). + // Skip after our own reorder — the worklet already set correct slots + // on the UI thread, and a redundant JS-side set() would be wasteful. + const skipNextSync = useRef(false) + const currentKeys = data.map(item => keyExtractor(item)).join(',') + useLayoutEffect(() => { + if (skipNextSync.current) { + skipNextSync.current = false + return + } + const nextSlots: Record = {} + data.forEach((item, i) => { + nextSlots[keyExtractor(item)] = i + }) + state.set({slots: nextSlots, activeKey: '', dragStartSlot: -1}) + dragY.set(0) + }, [currentKeys, data, keyExtractor, state, dragY]) + + const handleReorder = (sortedKeys: string[]) => { + skipNextSync.current = true + const byKey = new Map(data.map(item => [keyExtractor(item), item])) + onReorder(sortedKeys.map(key => byKey.get(key)!)) + onDragEnd?.() + } + + // Auto-scroll: runs every frame while a gesture is active. + useFrameCallback(() => { + if (!isGestureActive.get()) return + if (!scrollRef || !scrollOffset) return + + const s = state.get() + if (s.activeKey === '') return + + // Measure list and scroll view on first frame of drag. + // Use scrollOffset here (only once) since no lag has occurred yet. + if (!measureDone.get()) { + const scrollM = measure( + scrollRef as unknown as AnimatedRef, + ) + const listM = measure(listRef) + if (!scrollM || !listM) return + trackedScrollY.set(scrollOffset.get()) + listContentOffset.set(listM.pageY - scrollM.pageY + trackedScrollY.get()) + viewportHeight.set(scrollM.height) + measureDone.set(true) + } + + const startSlot = s.dragStartSlot + const currentDragY = dragY.get() + + // Use trackedScrollY (not scrollOffset) to avoid the one-frame lag + // after scrollTo() that causes a feedback loop. + const scrollY = trackedScrollY.get() + + // Item position relative to scroll viewport top. + const itemContentY = + listContentOffset.get() + startSlot * itemHeight + currentDragY + const itemViewportY = itemContentY - scrollY + const itemBottomViewportY = itemViewportY + itemHeight + + let scrollDelta = 0 + if (itemViewportY < AUTO_SCROLL_THRESHOLD) { + scrollDelta = -AUTO_SCROLL_SPEED + } else if ( + itemBottomViewportY > + viewportHeight.get() - AUTO_SCROLL_THRESHOLD + ) { + scrollDelta = AUTO_SCROLL_SPEED + } + + if (scrollDelta === 0) return + + // Don't scroll if the item is already at a list boundary. + const effectiveSlotPos = + (startSlot * itemHeight + currentDragY) / itemHeight + if (scrollDelta < 0 && effectiveSlotPos <= 0) return + if (scrollDelta > 0 && effectiveSlotPos >= data.length - 1) return + + // Don't scroll past the top. + if (scrollDelta < 0 && scrollY <= 0) return + + const newScrollY = Math.max(0, scrollY + scrollDelta) + scrollTo(scrollRef, 0, newScrollY, false) + trackedScrollY.set(newScrollY) + scrollCompensation.set(scrollCompensation.get() + (newScrollY - scrollY)) + }) + + // Render in stable key order so React never reorders native views. + // On Android, native ViewGroup child reordering causes a visual flash. + const sortedData = [...data].sort((a, b) => { + const ka = keyExtractor(a) + const kb = keyExtractor(b) + return ka < kb ? -1 : ka > kb ? 1 : 0 + }) + + return ( + + {sortedData.map(item => { + const key = keyExtractor(item) + return ( + + ) + })} + + ) +} + +function SortableItem({ + item, + itemKey, + itemCount, + itemHeight, + state, + dragY, + scrollCompensation, + isGestureActive, + measureDone, + renderItem, + onCommitReorder, + onDragStart, + onDragEnd, +}: { + item: T + itemKey: string + itemCount: number + itemHeight: number + state: Animated.SharedValue + dragY: Animated.SharedValue + scrollCompensation: SharedValue + isGestureActive: SharedValue + measureDone: SharedValue + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onCommitReorder: (sortedKeys: string[]) => void + onDragStart?: () => void + onDragEnd?: () => void +}) { + const t = useTheme() + const playHaptic = useHaptics() + + const lastHapticSlot = useSharedValue(-1) + + const gesture = Gesture.Pan() + .onStart(() => { + 'worklet' + const s = state.get() + const mySlot = s.slots[itemKey] + state.set({...s, activeKey: itemKey, dragStartSlot: mySlot}) + dragY.set(0) + scrollCompensation.set(0) + isGestureActive.set(true) + measureDone.set(false) + lastHapticSlot.set(mySlot) + if (onDragStart) { + runOnJS(onDragStart)() + } + runOnJS(playHaptic)() + }) + .onChange(e => { + 'worklet' + const startSlot = state.get().dragStartSlot + const minY = -startSlot * itemHeight + const maxY = (itemCount - 1 - startSlot) * itemHeight + // Include scroll compensation so the item tracks with auto-scroll. + const effectiveY = e.translationY + scrollCompensation.get() + const clampedY = Math.max(minY, Math.min(effectiveY, maxY)) + dragY.set(clampedY) + + const currentSlot = Math.round( + (startSlot * itemHeight + clampedY) / itemHeight, + ) + const clampedSlot = Math.max(0, Math.min(currentSlot, itemCount - 1)) + if (IS_IOS && clampedSlot !== lastHapticSlot.get()) { + lastHapticSlot.set(clampedSlot) + runOnJS(playHaptic)('Light') + } + }) + .onEnd(() => { + 'worklet' + // Stop auto-scroll BEFORE the snap animation. + isGestureActive.set(false) + const startSlot = state.get().dragStartSlot + const rawNewSlot = Math.round( + (startSlot * itemHeight + dragY.get()) / itemHeight, + ) + const newSlot = Math.max(0, Math.min(rawNewSlot, itemCount - 1)) + const snapOffset = (newSlot - startSlot) * itemHeight + + // Animate to the target slot, then commit. + dragY.set( + withTiming(snapOffset, {duration: 200}, finished => { + if (finished) { + if (newSlot !== startSlot) { + // Compute new slots on the UI thread so animated styles + // reflect final positions before React re-renders. + const cur = state.get() + const sorted: string[] = new Array(itemCount) + for (const key in cur.slots) { + sorted[cur.slots[key]] = key + } + const movedKey = sorted[startSlot] + sorted.splice(startSlot, 1) + sorted.splice(newSlot, 0, movedKey) + + const nextSlots: Record = {} + for (let i = 0; i < sorted.length; i++) { + nextSlots[sorted[i]] = i + } + + state.set({ + slots: nextSlots, + activeKey: '', + dragStartSlot: -1, + }) + dragY.set(0) + runOnJS(onCommitReorder)(sorted) + } else { + const s = state.get() + state.set({...s, activeKey: '', dragStartSlot: -1}) + dragY.set(0) + if (onDragEnd) { + runOnJS(onDragEnd)() + } + } + } + }), + ) + }) + // Reset if the gesture is cancelled without onEnd firing. + .onFinalize(() => { + 'worklet' + isGestureActive.set(false) + if (state.get().activeKey === itemKey && dragY.get() === 0) { + const s = state.get() + state.set({...s, activeKey: '', dragStartSlot: -1}) + if (onDragEnd) { + runOnJS(onDragEnd)() + } + } + }) + + // All vertical positioning is via translateY (no `top`). This avoids + // discrete jumps when slots change — Reanimated smoothly animates from + // the current translateY to the new target on every state transition. + // On first mount we skip the animation so items appear instantly. + const isFirstRender = useSharedValue(true) + + const animatedStyle = useAnimatedStyle(() => { + const s = state.get() + const mySlot = s.slots[itemKey] + if (mySlot === undefined) { + return {} + } + const baseY = mySlot * itemHeight + + // Active item: follow the finger with a slight scale-up and shadow. + if (s.activeKey === itemKey) { + return { + transform: [ + {translateY: s.dragStartSlot * itemHeight + dragY.get()}, + {scale: withSpring(1.03)}, + ], + zIndex: 999, + ...(IS_IOS + ? { + shadowColor: '#000', + shadowOffset: {width: 0, height: 1}, + shadowOpacity: withSpring(0.08), + shadowRadius: withSpring(4), + } + : { + elevation: withSpring(3), + }), + } + } + + // Reset for non-active states. Without this, shadow props + // set during dragging linger on the native view. + const inactive = { + ...(IS_IOS + ? { + shadowOpacity: withSpring(0), + shadowRadius: withSpring(0), + } + : { + elevation: withSpring(0), + }), + } + + // Another item is being dragged — shift to make room. + if (s.activeKey !== '') { + isFirstRender.set(false) + const currentDragPos = Math.round( + (s.dragStartSlot * itemHeight + dragY.get()) / itemHeight, + ) + const clampedPos = Math.max(0, Math.min(currentDragPos, itemCount - 1)) + + let offset = 0 + if ( + s.dragStartSlot < clampedPos && + mySlot > s.dragStartSlot && + mySlot <= clampedPos + ) { + offset = -itemHeight + } else if ( + s.dragStartSlot > clampedPos && + mySlot < s.dragStartSlot && + mySlot >= clampedPos + ) { + offset = itemHeight + } + + return { + transform: [ + {translateY: withTiming(baseY + offset, {duration: 200})}, + {scale: withSpring(1)}, + ], + zIndex: 0, + ...inactive, + } + } + + // Idle: sit at our slot. On first render use a direct value so items + // don't animate from y=0. After any drag, use withTiming so the + // shift→idle transition is smooth (no discrete jump). + if (isFirstRender.get()) { + isFirstRender.set(false) + return { + transform: [{translateY: baseY}, {scale: 1}], + zIndex: 0, + ...inactive, + } + } + + return { + transform: [{translateY: withTiming(baseY, {duration: 200})}, {scale: 1}], + zIndex: 0, + ...inactive, + } + }) + + const dragHandle = ( + + + + + + ) + + return ( + + {renderItem(item, dragHandle)} + + ) +} diff --git a/src/components/DraggableList/index.web.tsx b/src/components/DraggableList/index.web.tsx new file mode 100644 index 000000000..237a11be3 --- /dev/null +++ b/src/components/DraggableList/index.web.tsx @@ -0,0 +1,168 @@ +import {useState} from 'react' +import {View} from 'react-native' + +import {useTheme} from '#/alf' +import {DotGrid2x3_Stroke2_Corner0_Rounded as GripIcon} from '#/components/icons/DotGrid' + +/** + * Web implementation of SortableList using pointer events. + * See SortableList.tsx for the native version using gesture-handler + Reanimated. + */ + +interface SortableListProps { + data: T[] + keyExtractor: (item: T) => string + renderItem: (item: T, dragHandle: React.ReactNode) => React.ReactNode + onReorder: (data: T[]) => void + onDragStart?: () => void + onDragEnd?: () => void + /** Fixed row height used for position math. */ + itemHeight: number +} + +export function SortableList({ + data, + keyExtractor, + renderItem, + onReorder, + onDragStart, + onDragEnd, + itemHeight, +}: SortableListProps) { + const t = useTheme() + const [dragState, setDragState] = useState<{ + activeIndex: number + currentY: number + startY: number + } | null>(null) + + const getNewPosition = (state: { + activeIndex: number + currentY: number + startY: number + }) => { + const translationY = state.currentY - state.startY + const rawNewPos = Math.round( + (state.activeIndex * itemHeight + translationY) / itemHeight, + ) + return Math.max(0, Math.min(rawNewPos, data.length - 1)) + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (!dragState) return + e.preventDefault() + setDragState(prev => (prev ? {...prev, currentY: e.clientY} : null)) + } + + const handlePointerUp = () => { + if (!dragState) return + const newPos = getNewPosition(dragState) + if (newPos !== dragState.activeIndex) { + const next = [...data] + const [moved] = next.splice(dragState.activeIndex, 1) + next.splice(newPos, 0, moved) + onReorder(next) + } + setDragState(null) + onDragEnd?.() + } + + const handlePointerDown = (e: React.PointerEvent, index: number) => { + e.preventDefault() + ;(e.target as HTMLElement).setPointerCapture(e.pointerId) + setDragState({activeIndex: index, currentY: e.clientY, startY: e.clientY}) + onDragStart?.() + } + + const newPos = dragState ? getNewPosition(dragState) : -1 + + return ( + + {data.map((item, index) => { + const isActive = dragState?.activeIndex === index + + // Clamp translation so the item stays within list bounds. + const rawTranslationY = isActive + ? dragState.currentY - dragState.startY + : 0 + const translationY = isActive + ? Math.max( + -index * itemHeight, + Math.min(rawTranslationY, (data.length - 1 - index) * itemHeight), + ) + : 0 + + // Non-dragged items shift to make room for the dragged item. + let offset = 0 + if (dragState && !isActive) { + const orig = dragState.activeIndex + if (orig < newPos && index > orig && index <= newPos) { + offset = -itemHeight + } else if (orig > newPos && index < orig && index >= newPos) { + offset = itemHeight + } + } + + const dragHandle = ( +
) => + handlePointerDown(e, index) + } + style={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + paddingLeft: 8, + paddingRight: 8, + paddingTop: 12, + paddingBottom: 12, + cursor: isActive ? 'grabbing' : 'grab', + touchAction: 'none', + userSelect: 'none', + }}> + +
+ ) + + return ( + + {renderItem(item, dragHandle)} + + ) + })} +
+ ) +} diff --git a/src/components/PostControls/PostMenu/index.tsx b/src/components/PostControls/PostMenu/index.tsx index 65a948d69..dbf074499 100644 --- a/src/components/PostControls/PostMenu/index.tsx +++ b/src/components/PostControls/PostMenu/index.tsx @@ -11,7 +11,7 @@ import {useLingui} from '@lingui/react' import {type Shadow} from '#/state/cache/post-shadow' import {EventStopper} from '#/view/com/util/EventStopper' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {useMenuControl} from '#/components/Menu' import * as Menu from '#/components/Menu' import {PostControlButton, PostControlButtonIcon} from '../PostControlButton' diff --git a/src/components/dms/ActionsWrapper.web.tsx b/src/components/dms/ActionsWrapper.web.tsx index 18eb4161c..6b95e7fba 100644 --- a/src/components/dms/ActionsWrapper.web.tsx +++ b/src/components/dms/ActionsWrapper.web.tsx @@ -9,7 +9,7 @@ import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {MessageContextMenu} from '#/components/dms/MessageContextMenu' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontalIcon} from '#/components/icons/DotGrid' import {EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmileIcon} from '#/components/icons/Emoji' import {EmojiReactionPicker} from './EmojiReactionPicker' import {hasReachedReactionLimit} from './util' diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 713605a7a..6fcca2081 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -24,7 +24,7 @@ import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt' import {ReportConversationPrompt} from '#/components/dms/ReportConversationPrompt' import {ArrowBoxLeft_Stroke2_Corner0_Rounded as ArrowBoxLeft} from '#/components/icons/ArrowBoxLeft' import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute' import { diff --git a/src/components/dms/EmojiReactionPicker.web.tsx b/src/components/dms/EmojiReactionPicker.web.tsx index 6a9623879..6be85efb4 100644 --- a/src/components/dms/EmojiReactionPicker.web.tsx +++ b/src/components/dms/EmojiReactionPicker.web.tsx @@ -10,7 +10,7 @@ import {useSession} from '#/state/session' import {type Emoji} from '#/view/com/composer/text-input/web/EmojiPicker' import {useWebPreloadEmoji} from '#/view/com/composer/text-input/web/useWebPreloadEmoji' import {atoms as a, flatten, useTheme} from '#/alf' -import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import * as Menu from '#/components/Menu' import {type TriggerProps} from '#/components/Menu/types' import {Text} from '#/components/Typography' diff --git a/src/components/icons/DotGrid.tsx b/src/components/icons/DotGrid.tsx index c50d7a440..2a2102666 100644 --- a/src/components/icons/DotGrid.tsx +++ b/src/components/icons/DotGrid.tsx @@ -1,5 +1,9 @@ import {createSinglePathSVG} from './TEMPLATE' -export const DotGrid_Stroke2_Corner0_Rounded = createSinglePathSVG({ +export const DotGrid3x1_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M2 12a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm16 0a2 2 0 1 1 4 0 2 2 0 0 1-4 0Zm-6-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z', }) + +export const DotGrid2x3_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M9 17a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm-6-7a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM9 3a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm6 0a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z', +}) diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx index 2201fee74..58f6fc594 100644 --- a/src/screens/Profile/components/ProfileFeedHeader.tsx +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -30,7 +30,7 @@ import {Divider} from '#/components/Divider' import {useRichText} from '#/components/hooks/useRichText' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as Share} from '#/components/icons/ArrowOutOfBox' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import { Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, Heart2_Stroke2_Corner0_Rounded as Heart, diff --git a/src/screens/ProfileList/components/MoreOptionsMenu.tsx b/src/screens/ProfileList/components/MoreOptionsMenu.tsx index d414c5351..d15664618 100644 --- a/src/screens/ProfileList/components/MoreOptionsMenu.tsx +++ b/src/screens/ProfileList/components/MoreOptionsMenu.tsx @@ -20,7 +20,7 @@ import {useDialogControl} from '#/components/Dialog' import {CreateOrEditListDialog} from '#/components/dialogs/lists/CreateOrEditListDialog' import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ShareIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLink} from '#/components/icons/ChainLink' -import {DotGrid_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotGridIcon} from '#/components/icons/DotGrid' import {PencilLine_Stroke2_Corner0_Rounded as PencilLineIcon} from '#/components/icons/Pencil' import {PersonCheck_Stroke2_Corner0_Rounded as PersonCheckIcon} from '#/components/icons/Person' import {Pin_Stroke2_Corner0_Rounded as PinIcon} from '#/components/icons/Pin' diff --git a/src/screens/SavedFeeds.tsx b/src/screens/SavedFeeds.tsx index df5729724..4b8fcaddb 100644 --- a/src/screens/SavedFeeds.tsx +++ b/src/screens/SavedFeeds.tsx @@ -1,6 +1,7 @@ import {useCallback, useState} from 'react' import {View} from 'react-native' -import Animated, {LinearTransition} from 'react-native-reanimated' +import type Animated from 'react-native-reanimated' +import {useAnimatedRef, useScrollViewOffset} from 'react-native-reanimated' import {type AppBskyActorDefs} from '@atproto/api' import {TID} from '@atproto/common-web' import {msg} from '@lingui/core/macro' @@ -16,6 +17,7 @@ import { type NavigationProp, } from '#/lib/routes/types' import {logger} from '#/logger' +import {useA11y} from '#/state/a11y' import { useOverwriteSavedFeedsMutation, usePreferencesQuery, @@ -29,6 +31,7 @@ import {NoSavedFeedsOfAnyType} from '#/screens/Feeds/NoSavedFeedsOfAnyType' import {atoms as a, useBreakpoints, useTheme} from '#/alf' import {Admonition} from '#/components/Admonition' import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import {SortableList} from '#/components/DraggableList' import { ArrowBottom_Stroke2_Corner0_Rounded as ArrowDownIcon, ArrowTop_Stroke2_Corner0_Rounded as ArrowUpIcon, @@ -45,9 +48,13 @@ import {Text} from '#/components/Typography' type Props = NativeStackScreenProps export function SavedFeeds({}: Props) { const {data: preferences} = usePreferencesQuery() + const {screenReaderEnabled} = useA11y() if (!preferences) { return } + if (screenReaderEnabled) { + return + } return } @@ -63,6 +70,8 @@ function SavedFeedsInner({ const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = useOverwriteSavedFeedsMutation() const navigation = useNavigation() + const scrollRef = useAnimatedRef() + const scrollOffset = useScrollViewOffset(scrollRef) /* * Use optimistic data if exists and no error, otherwise fallback to remote @@ -77,6 +86,7 @@ function SavedFeedsInner({ const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 const noFollowingFeed = currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType + const [isDragging, setIsDragging] = useState(false) useFocusEffect( useCallback(() => { @@ -122,7 +132,7 @@ function SavedFeedsInner({ - + {noSavedFeedsOfAnyType && ( ) : ( - pinnedFeeds.map(f => ( - - )) + f.id} + itemHeight={68} + scrollRef={scrollRef} + scrollOffset={scrollOffset} + onDragStart={() => setIsDragging(true)} + onDragEnd={() => setIsDragging(false)} + onReorder={reordered => { + setCurrentFeeds([...reordered, ...unpinnedFeeds]) + }} + renderItem={(feed, dragHandle) => ( + + )} + /> ) ) : ( @@ -193,13 +213,11 @@ function SavedFeedsInner({ ) : ( unpinnedFeeds.map(f => ( - )) ) @@ -231,24 +249,209 @@ function SavedFeedsInner({ ) } -function ListItem({ +function SavedFeedsA11y({ + preferences, +}: { + preferences: UsePreferencesQueryResponse +}) { + const t = useTheme() + const {_} = useLingui() + const {gtMobile} = useBreakpoints() + const setMinimalShellMode = useSetMinimalShellMode() + const {mutateAsync: overwriteSavedFeeds, isPending: isOverwritePending} = + useOverwriteSavedFeedsMutation() + const navigation = useNavigation() + + const [currentFeeds, setCurrentFeeds] = useState( + () => preferences.savedFeeds || [], + ) + const hasUnsavedChanges = currentFeeds !== preferences.savedFeeds + const pinnedFeeds = currentFeeds.filter(f => f.pinned) + const unpinnedFeeds = currentFeeds.filter(f => !f.pinned) + const noSavedFeedsOfAnyType = pinnedFeeds.length + unpinnedFeeds.length === 0 + const noFollowingFeed = + currentFeeds.every(f => f.type !== 'timeline') && !noSavedFeedsOfAnyType + + useFocusEffect( + useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + const onSaveChanges = async () => { + try { + await overwriteSavedFeeds(currentFeeds) + Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Feeds') + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') + logger.error('Failed to toggle pinned feed', {message: e}) + } + } + + const onMoveUp = (index: number) => { + const pinned = [...pinnedFeeds] + ;[pinned[index - 1], pinned[index]] = [pinned[index], pinned[index - 1]] + setCurrentFeeds([...pinned, ...unpinnedFeeds]) + } + + const onMoveDown = (index: number) => { + const pinned = [...pinnedFeeds] + ;[pinned[index], pinned[index + 1]] = [pinned[index + 1], pinned[index]] + setCurrentFeeds([...pinned, ...unpinnedFeeds]) + } + + return ( + + + + + + Feeds + + + + + + + {noSavedFeedsOfAnyType && ( + + + setCurrentFeeds( + RECOMMENDED_SAVED_FEEDS.map(f => ({ + ...f, + id: TID.nextStr(), + })), + ) + } + /> + + )} + + + Pinned Feeds + + + {!pinnedFeeds.length ? ( + + + You don't have any pinned feeds. + + + ) : ( + pinnedFeeds.map((feed, i) => ( + onMoveUp(i)} + onMoveDown={() => onMoveDown(i)} + /> + )) + )} + + {noFollowingFeed && ( + + + setCurrentFeeds(feeds => [ + ...feeds, + {...TIMELINE_SAVED_FEED, id: TID.next().toString()}, + ]) + } + /> + + )} + + + Saved Feeds + + + {!unpinnedFeeds.length ? ( + + + You don't have any saved feeds. + + + ) : ( + unpinnedFeeds.map(f => ( + + )) + )} + + + + + Feeds are custom algorithms that users build with a little coding + expertise.{' '} + + See this guide + {' '} + for more information. + + + + + + ) +} + +function PinnedFeedItem({ feed, - isPinned, currentFeeds, setCurrentFeeds, + dragHandle, + index, + total, + onMoveUp, + onMoveDown, }: { feed: AppBskyActorDefs.SavedFeed - isPinned: boolean currentFeeds: AppBskyActorDefs.SavedFeed[] - setCurrentFeeds: React.Dispatch - preferences: UsePreferencesQueryResponse + setCurrentFeeds: React.Dispatch< + React.SetStateAction + > + dragHandle?: React.ReactNode + index?: number + total?: number + onMoveUp?: () => void + onMoveDown?: () => void }) { const {_} = useLingui() const t = useTheme() const playHaptic = useHaptics() const feedUri = feed.value - const onTogglePinned = async () => { + const onTogglePinned = () => { playHaptic() setCurrentFeeds( currentFeeds.map(f => @@ -257,68 +460,35 @@ function ListItem({ ) } - const onPressUp = async () => { - if (!isPinned) return - - const nextFeeds = currentFeeds.slice() - const ids = currentFeeds.map(f => f.id) - const index = ids.indexOf(feed.id) - const nextIndex = index - 1 - - if (index === -1 || index === 0) return - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ - nextFeeds[nextIndex], - nextFeeds[index], - ] - - setCurrentFeeds(nextFeeds) - } - - const onPressDown = async () => { - if (!isPinned) return - - const nextFeeds = currentFeeds.slice() - const ids = currentFeeds.map(f => f.id) - const index = ids.indexOf(feed.id) - const nextIndex = index + 1 - - if (index === -1 || index >= nextFeeds.filter(f => f.pinned).length - 1) - return - ;[nextFeeds[index], nextFeeds[nextIndex]] = [ - nextFeeds[nextIndex], - nextFeeds[index], - ] - - setCurrentFeeds(nextFeeds) - } - - const onPressRemove = async () => { - playHaptic() - setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) - } - return ( - + {feed.type === 'timeline' ? ( ) : ( )} - - {isPinned ? ( + + + {onMoveUp !== undefined ? ( <> ) : ( - + dragHandle )} + + + ) +} + +function UnpinnedFeedItem({ + feed, + currentFeeds, + setCurrentFeeds, +}: { + feed: AppBskyActorDefs.SavedFeed + currentFeeds: AppBskyActorDefs.SavedFeed[] + setCurrentFeeds: React.Dispatch< + React.SetStateAction + > +}) { + const {_} = useLingui() + const t = useTheme() + const playHaptic = useHaptics() + const feedUri = feed.value + + const onTogglePinned = () => { + playHaptic() + setCurrentFeeds( + currentFeeds.map(f => + f.id === feed.id ? {...feed, pinned: !feed.pinned} : f, + ), + ) + } + + const onPressRemove = () => { + playHaptic() + setCurrentFeeds(currentFeeds.filter(f => f.id !== feed.id)) + } + + return ( + + {feed.type === 'timeline' ? ( + + ) : ( + + )} + + - + ) } diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 245a70f47..575bd4cce 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -45,7 +45,7 @@ import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/ import {CircleQuestion_Stroke2_Corner2_Rounded as CircleQuestionIcon} from '#/components/icons/CircleQuestion' import {CodeBrackets_Stroke2_Corner2_Rounded as CodeBracketsIcon} from '#/components/icons/CodeBrackets' import {Contacts_Stroke2_Corner2_Rounded as ContactsIcon} from '#/components/icons/Contacts' -import {DotGrid_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsHorizontal} from '#/components/icons/DotGrid' import {Earth_Stroke2_Corner2_Rounded as EarthIcon} from '#/components/icons/Globe' import {Lock_Stroke2_Corner2_Rounded as LockIcon} from '#/components/icons/Lock' import {PaintRoller_Stroke2_Corner2_Rounded as PaintRollerIcon} from '#/components/icons/PaintRoller' diff --git a/src/screens/StarterPack/StarterPackScreen.tsx b/src/screens/StarterPack/StarterPackScreen.tsx index e08cbd01b..cb3ef828b 100644 --- a/src/screens/StarterPack/StarterPackScreen.tsx +++ b/src/screens/StarterPack/StarterPackScreen.tsx @@ -55,7 +55,7 @@ import {CreateListFromStarterPackDialog} from '#/components/dialogs/lists/Create import {ArrowOutOfBoxModified_Stroke2_Corner2_Rounded as ArrowOutOfBoxIcon} from '#/components/icons/ArrowOutOfBox' import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/icons/ChainLink' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {ListSparkle_Stroke2_Corner0_Rounded as ListSparkle} from '#/components/icons/ListSparkle' import {Pencil_Stroke2_Corner0_Rounded as Pencil} from '#/components/icons/Pencil' import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' diff --git a/src/view/com/composer/drafts/DraftItem.tsx b/src/view/com/composer/drafts/DraftItem.tsx index 84d9e1a90..8859216c3 100644 --- a/src/view/com/composer/drafts/DraftItem.tsx +++ b/src/view/com/composer/drafts/DraftItem.tsx @@ -11,7 +11,7 @@ import {atoms as a, select, useTheme} from '#/alf' import {Button} from '#/components/Button' import {CirclePlus_Stroke2_Corner0_Rounded as CirclePlusIcon} from '#/components/icons/CirclePlus' import {type Props as SVGIconProps} from '#/components/icons/common' -import {DotGrid_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as DotsIcon} from '#/components/icons/DotGrid' import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote' import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning' import * as MediaPreview from '#/components/MediaPreview' diff --git a/src/view/com/profile/ProfileMenu.tsx b/src/view/com/profile/ProfileMenu.tsx index 209f42ba9..900989c0c 100644 --- a/src/view/com/profile/ProfileMenu.tsx +++ b/src/view/com/profile/ProfileMenu.tsx @@ -32,7 +32,7 @@ import {ChainLink_Stroke2_Corner0_Rounded as ChainLinkIcon} from '#/components/i import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheckIcon} from '#/components/icons/CircleCheck' import {CircleX_Stroke2_Corner0_Rounded as CircleXIcon} from '#/components/icons/CircleX' import {Clipboard_Stroke2_Corner2_Rounded as ClipboardIcon} from '#/components/icons/Clipboard' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Flag_Stroke2_Corner0_Rounded as Flag} from '#/components/icons/Flag' import {ListSparkle_Stroke2_Corner0_Rounded as List} from '#/components/icons/ListSparkle' import {Live_Stroke2_Corner0_Rounded as LiveIcon} from '#/components/icons/Live' diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index 518bd1560..a35f3ec1c 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -43,7 +43,7 @@ import { BulletList_Filled_Corner0_Rounded as ListFilled, BulletList_Stroke2_Corner0_Rounded as List, } from '#/components/icons/BulletList' -import {DotGrid_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as EllipsisIcon} from '#/components/icons/DotGrid' import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' import { Hashtag_Filled_Corner0_Rounded as HashtagFilled, diff --git a/src/view/shell/desktop/SidebarTrendingTopics.tsx b/src/view/shell/desktop/SidebarTrendingTopics.tsx index d396441d4..3a054faa6 100644 --- a/src/view/shell/desktop/SidebarTrendingTopics.tsx +++ b/src/view/shell/desktop/SidebarTrendingTopics.tsx @@ -11,7 +11,7 @@ import {useTrendingTopics} from '#/state/queries/trending/useTrendingTopics' import {useTrendingConfig} from '#/state/service-config' import {atoms as a, useTheme} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' -import {DotGrid_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' +import {DotGrid3x1_Stroke2_Corner0_Rounded as Ellipsis} from '#/components/icons/DotGrid' import {Trending3_Stroke2_Corner1_Rounded as TrendingIcon} from '#/components/icons/Trending' import * as Prompt from '#/components/Prompt' import {TrendingTopicLink} from '#/components/TrendingTopics' -- 2.51.2 From db9c2a1596d5ac93b38e16212e2943e149c6aac3 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Thu, 26 Feb 2026 03:07:29 +0000 Subject: [PATCH 11/43] Nightly source-language update --- src/locale/locales/en/messages.po | 66 +++++++++++++++++++------------ 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 161ceb706..cc2f8dda2 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -4175,7 +4175,8 @@ msgid "Feedback sent to feed operator" msgstr "" #: src/Navigation.tsx:585 -#: src/screens/SavedFeeds.tsx:108 +#: src/screens/SavedFeeds.tsx:118 +#: src/screens/SavedFeeds.tsx:314 #: src/screens/Search/SearchResults.tsx:79 #: src/screens/StarterPack/StarterPackScreen.tsx:196 #: src/view/screens/Feeds.tsx:512 @@ -4185,12 +4186,14 @@ msgstr "" msgid "Feeds" msgstr "" -#: src/screens/SavedFeeds.tsx:215 +#: src/screens/SavedFeeds.tsx:233 +#: src/screens/SavedFeeds.tsx:409 msgid "Feeds are custom algorithms that users build with a little coding expertise. <0>See this guide for more information." msgstr "" #: src/components/FeedCard.tsx:315 -#: src/screens/SavedFeeds.tsx:90 +#: src/screens/SavedFeeds.tsx:100 +#: src/screens/SavedFeeds.tsx:284 msgctxt "toast" msgid "Feeds updated!" msgstr "" @@ -4440,7 +4443,7 @@ msgstr "" msgid "Following" msgstr "" -#: src/screens/SavedFeeds.tsx:410 +#: src/screens/SavedFeeds.tsx:629 #: src/view/screens/Feeds.tsx:604 msgctxt "feed-name" msgid "Following" @@ -6135,11 +6138,11 @@ msgstr "" msgid "More options" msgstr "" -#: src/screens/SavedFeeds.tsx:329 +#: src/screens/SavedFeeds.tsx:499 msgid "Move feed down" msgstr "" -#: src/screens/SavedFeeds.tsx:320 +#: src/screens/SavedFeeds.tsx:489 msgid "Move feed up" msgstr "" @@ -6157,10 +6160,11 @@ msgctxt "video" msgid "Mute" msgstr "" -#: src/components/RichTextTag.tsx:151 -#: src/components/RichTextTag.tsx:164 -msgid "Mute {tag}" -msgstr "" +#. placeholder {0}: isCashtag ? tag : `#${tag}` +#: src/components/RichTextTag.tsx:154 +#: src/components/RichTextTag.tsx:170 +msgid "Mute {0}" +msgstr "Mute {0}" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:688 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:694 @@ -7228,7 +7232,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:522 #: src/screens/Profile/components/ProfileFeedHeader.tsx:528 -#: src/screens/SavedFeeds.tsx:351 +#: src/screens/SavedFeeds.tsx:570 msgid "Pin feed" msgstr "" @@ -7260,7 +7264,8 @@ msgstr "" msgid "Pinned {0} to Home" msgstr "" -#: src/screens/SavedFeeds.tsx:142 +#: src/screens/SavedFeeds.tsx:152 +#: src/screens/SavedFeeds.tsx:348 msgid "Pinned Feeds" msgstr "" @@ -7982,7 +7987,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:328 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:176 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:179 -#: src/screens/SavedFeeds.tsx:340 +#: src/screens/SavedFeeds.tsx:560 msgid "Remove from my feeds" msgstr "" @@ -8485,7 +8490,8 @@ msgstr "" #: src/features/liveNow/components/EditLiveDialog.tsx:211 #: src/screens/Profile/Header/EditProfileDialog.tsx:237 #: src/screens/Profile/Header/EditProfileDialog.tsx:251 -#: src/screens/SavedFeeds.tsx:120 +#: src/screens/SavedFeeds.tsx:130 +#: src/screens/SavedFeeds.tsx:326 #: src/screens/Settings/components/ChangeHandleDialog.tsx:271 #: src/view/com/composer/GifAltText.tsx:196 #: src/view/com/composer/GifAltText.tsx:205 @@ -8507,8 +8513,10 @@ msgstr "" #: src/components/activity-notifications/SubscribeProfileDialog.tsx:192 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:201 -#: src/screens/SavedFeeds.tsx:116 -#: src/screens/SavedFeeds.tsx:120 +#: src/screens/SavedFeeds.tsx:126 +#: src/screens/SavedFeeds.tsx:130 +#: src/screens/SavedFeeds.tsx:322 +#: src/screens/SavedFeeds.tsx:326 #: src/view/com/composer/Composer.tsx:1255 #: src/view/com/composer/drafts/DraftsButton.tsx:125 msgid "Save changes" @@ -8558,7 +8566,8 @@ msgctxt "link to bookmarks screen" msgid "Saved" msgstr "" -#: src/screens/SavedFeeds.tsx:184 +#: src/screens/SavedFeeds.tsx:204 +#: src/screens/SavedFeeds.tsx:386 msgid "Saved Feeds" msgstr "" @@ -8751,7 +8760,8 @@ msgstr "" msgid "See suggested accounts" msgstr "" -#: src/screens/SavedFeeds.tsx:220 +#: src/screens/SavedFeeds.tsx:238 +#: src/screens/SavedFeeds.tsx:414 msgid "See this guide" msgstr "" @@ -10010,7 +10020,8 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:180 #: src/screens/ProfileList/components/Header.tsx:91 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:79 -#: src/screens/SavedFeeds.tsx:97 +#: src/screens/SavedFeeds.tsx:107 +#: src/screens/SavedFeeds.tsx:291 msgid "There was an issue contacting the server" msgstr "" @@ -10707,10 +10718,11 @@ msgctxt "video" msgid "Unmute" msgstr "" -#: src/components/RichTextTag.tsx:151 -#: src/components/RichTextTag.tsx:164 -msgid "Unmute {tag}" -msgstr "" +#. placeholder {0}: isCashtag ? tag : `#${tag}` +#: src/components/RichTextTag.tsx:153 +#: src/components/RichTextTag.tsx:169 +msgid "Unmute {0}" +msgstr "Unmute {0}" #: src/components/PostControls/PostMenu/PostMenuItems.tsx:687 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:693 @@ -10744,7 +10756,7 @@ msgstr "" #: src/screens/Profile/components/ProfileFeedHeader.tsx:522 #: src/screens/Profile/components/ProfileFeedHeader.tsx:528 -#: src/screens/SavedFeeds.tsx:351 +#: src/screens/SavedFeeds.tsx:478 msgid "Unpin feed" msgstr "" @@ -11848,11 +11860,13 @@ msgstr "" msgid "You don't have any lists yet." msgstr "" -#: src/screens/SavedFeeds.tsx:149 +#: src/screens/SavedFeeds.tsx:159 +#: src/screens/SavedFeeds.tsx:354 msgid "You don't have any pinned feeds." msgstr "" -#: src/screens/SavedFeeds.tsx:191 +#: src/screens/SavedFeeds.tsx:211 +#: src/screens/SavedFeeds.tsx:392 msgid "You don't have any saved feeds." msgstr "" -- 2.51.2 From 398df1a4b51fb6d3d7145445173992781a1c0730 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 26 Feb 2026 05:06:16 -0500 Subject: [PATCH 12/43] ensure app session state refreshes on reactivation (#9954) Co-authored-by: Samuel Newman Co-authored-by: Claude Opus 4.6 --- src/screens/Deactivated.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/screens/Deactivated.tsx b/src/screens/Deactivated.tsx index 9500f27a2..363151bd8 100644 --- a/src/screens/Deactivated.tsx +++ b/src/screens/Deactivated.tsx @@ -14,6 +14,7 @@ import { useSession, useSessionApi, } from '#/state/session' +import {agentToSessionAccountOrThrow} from '#/state/session/agent' import {useLoggedOutViewControls} from '#/state/shell/logged-out' import {Logo} from '#/view/icons/Logo' import {atoms as a, useTheme} from '#/alf' @@ -36,7 +37,7 @@ export function Deactivated() { const {onPressSwitchAccount, pendingDid} = useAccountSwitcher() const {setShowLoggedOut} = useLoggedOutViewControls() const hasOtherAccounts = accounts.length > 1 - const {logoutCurrentAccount} = useSessionApi() + const {logoutCurrentAccount, resumeSession} = useSessionApi() const agent = useAgent() const [pending, setPending] = React.useState(false) const [error, setError] = React.useState() @@ -72,7 +73,8 @@ export function Deactivated() { setPending(true) await agent.com.atproto.server.activateAccount() await queryClient.resetQueries() - await agent.resumeSession(agent.session!) + const account = agentToSessionAccountOrThrow(agent) + await resumeSession({...account, active: true, status: undefined}) } catch (e: any) { switch (e.message) { case 'Bad token scope': @@ -93,7 +95,7 @@ export function Deactivated() { } finally { setPending(false) } - }, [_, agent, setPending, setError, queryClient]) + }, [_, agent, queryClient, resumeSession]) return ( -- 2.51.2 From 9b24d75c9c3a13e424670758b5f5f4767b91f443 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 26 Feb 2026 05:06:30 -0500 Subject: [PATCH 13/43] [APP-1876] Fix pull-to-refresh getting stuck on tab switch (#9951) --- src/view/com/notifications/NotificationFeed.tsx | 6 ++++++ src/view/com/posts/PostFeed.tsx | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index fb7d982ea..2347f9ebe 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -162,6 +162,12 @@ export function NotificationFeed({ [isFetchingNextPage], ) + React.useEffect(() => { + if (!enabled) { + setIsPTRing(false) + } + }, [enabled]) + return ( {error && ( diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index 9ae241f9b..903d68f04 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -683,6 +683,12 @@ let PostFeed = ({ blockedOrMutedAuthors, ]) + useEffect(() => { + if (enabled === false) { + setIsPTRing(false) + } + }, [enabled]) + // events // = -- 2.51.2 From 65c4b7833d869a7405a06381ddb63ab9e835d347 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 26 Feb 2026 15:00:12 +0000 Subject: [PATCH 14/43] Blur effect on home screen (iOS 26) (#9935) --- src/Navigation.tsx | 25 +++++++++++++++++--- src/components/hooks/useHeaderOffset.ts | 11 +++++---- src/lib/hooks/useMinimalShellTransform.ts | 7 +++++- src/view/com/home/HomeHeaderLayoutMobile.tsx | 7 ++++-- src/view/com/util/MainScrollProvider.tsx | 9 +++++-- src/view/screens/Home.tsx | 4 ++-- 6 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index feb906606..4d05b95dc 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -138,7 +138,7 @@ import { } from '#/components/dialogs/EmailDialog' import {useAnalytics} from '#/analytics' import {setNavigationMetadata} from '#/analytics/metadata' -import {IS_NATIVE, IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' @@ -685,10 +685,29 @@ function screenOptions(t: Theme) { function HomeTabNavigator() { const t = useTheme() + const BLURRED_SCROLL_EDGE_EFFECT = IS_LIQUID_GLASS + ? ({ + headerShown: true, + headerTransparent: true, + headerTitle: '', + scrollEdgeEffects: { + top: 'soft', + }, + } as const) + : {} + return ( - HomeScreen} /> - HomeScreen} /> + HomeScreen} + options={BLURRED_SCROLL_EDGE_EFFECT} + /> + HomeScreen} + options={BLURRED_SCROLL_EDGE_EFFECT} + /> {commonScreens(HomeTab as typeof Flat)} ) diff --git a/src/components/hooks/useHeaderOffset.ts b/src/components/hooks/useHeaderOffset.ts index 2d18fb99b..c965f522f 100644 --- a/src/components/hooks/useHeaderOffset.ts +++ b/src/components/hooks/useHeaderOffset.ts @@ -1,14 +1,17 @@ import {useWindowDimensions} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' +import {useBreakpoints} from '#/alf' +import {IS_LIQUID_GLASS} from '#/env' export function useHeaderOffset() { - const {isDesktop, isTablet} = useWebMediaQueries() + const {gtMobile} = useBreakpoints() const {fontScale} = useWindowDimensions() - if (isDesktop || isTablet) { + const insets = useSafeAreaInsets() + if (gtMobile) { return 0 } - const navBarHeight = 52 + const navBarHeight = 52 + (IS_LIQUID_GLASS ? insets.top : 0) const tabBarPad = 10 + 10 + 3 // padding + border const normalLineHeight = 20 // matches tab bar const tabBarText = normalLineHeight * fontScale diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts index 6f16fa0f9..b24042977 100644 --- a/src/lib/hooks/useMinimalShellTransform.ts +++ b/src/lib/hooks/useMinimalShellTransform.ts @@ -1,13 +1,18 @@ import {interpolate, useAnimatedStyle} from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useMinimalShellMode} from '#/state/shell/minimal-mode' import {useShellLayout} from '#/state/shell/shell-layout' +import {IS_LIQUID_GLASS} from '#/env' // Keep these separated so that we only pay for useAnimatedStyle that gets used. export function useMinimalShellHeaderTransform() { const {headerMode} = useMinimalShellMode() const {headerHeight} = useShellLayout() + const {top: topInset} = useSafeAreaInsets() + + const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 const headerTransform = useAnimatedStyle(() => { const headerModeValue = headerMode.get() @@ -19,7 +24,7 @@ export function useMinimalShellHeaderTransform() { translateY: interpolate( headerModeValue, [0, 1], - [0, -headerHeight.get()], + [0, headerPinnedHeight - headerHeight.get()], ), }, ], diff --git a/src/view/com/home/HomeHeaderLayoutMobile.tsx b/src/view/com/home/HomeHeaderLayoutMobile.tsx index 2f3a9b73c..3fcbc3c93 100644 --- a/src/view/com/home/HomeHeaderLayoutMobile.tsx +++ b/src/view/com/home/HomeHeaderLayoutMobile.tsx @@ -1,6 +1,6 @@ -import {type JSX} from 'react' import {View} from 'react-native' import Animated from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -17,16 +17,18 @@ import {ButtonIcon} from '#/components/Button' import {Hashtag_Stroke2_Corner0_Rounded as FeedsIcon} from '#/components/icons/Hashtag' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' +import {IS_LIQUID_GLASS} from '#/env' export function HomeHeaderLayoutMobile({ children, }: { children: React.ReactNode - tabBarAnchor: JSX.Element | null | undefined + tabBarAnchor: React.ReactElement | null | undefined }) { const t = useTheme() const {_} = useLingui() const {headerHeight} = useShellLayout() + const insets = useSafeAreaInsets() const headerMinimalShellTransform = useMinimalShellHeaderTransform() const {hasSession} = useSession() const playHaptic = useHaptics() @@ -42,6 +44,7 @@ export function HomeHeaderLayoutMobile({ left: 0, right: 0, }, + IS_LIQUID_GLASS && {paddingTop: insets.top}, headerMinimalShellTransform, ]} onLayout={e => { diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx index 56ada276e..917dd999b 100644 --- a/src/view/com/util/MainScrollProvider.tsx +++ b/src/view/com/util/MainScrollProvider.tsx @@ -6,18 +6,21 @@ import { useSharedValue, withSpring, } from 'react-native-reanimated' +import {useSafeAreaInsets} from 'react-native-safe-area-context' import EventEmitter from 'eventemitter3' import {ScrollProvider} from '#/lib/ScrollContext' import {useMinimalShellMode} from '#/state/shell' import {useShellLayout} from '#/state/shell/shell-layout' -import {IS_NATIVE, IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_NATIVE, IS_WEB} from '#/env' const WEB_HIDE_SHELL_THRESHOLD = 200 export function MainScrollProvider({children}: {children: React.ReactNode}) { const {headerHeight} = useShellLayout() const {headerMode} = useMinimalShellMode() + const {top: topInset} = useSafeAreaInsets() + const headerPinnedHeight = IS_LIQUID_GLASS ? topInset : 0 const startDragOffset = useSharedValue(null) const startMode = useSharedValue(null) const didJustRestoreScroll = useSharedValue(false) @@ -126,9 +129,10 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { // The "mode" value is always between 0 and 1. // Figure out how much to move it based on the current dragged distance. const dy = offsetY - startDragOffsetValue + const hideDistance = headerHeight.get() - headerPinnedHeight const dProgress = interpolate( dy, - [-headerHeight.get(), headerHeight.get()], + [-hideDistance, hideDistance], [-1, 1], ) const newValue = clamp(startModeValue + dProgress, 0, 1) @@ -156,6 +160,7 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) { }, [ headerHeight, + headerPinnedHeight, headerMode, setMode, startDragOffset, diff --git a/src/view/screens/Home.tsx b/src/view/screens/Home.tsx index 4ee022167..592cf23c0 100644 --- a/src/view/screens/Home.tsx +++ b/src/view/screens/Home.tsx @@ -36,7 +36,7 @@ import {FollowingEndOfFeed} from '#/view/com/posts/FollowingEndOfFeed' import {NoFeedsPinned} from '#/screens/Home/NoFeedsPinned' import * as Layout from '#/components/Layout' import {useAnalytics} from '#/analytics' -import {IS_WEB} from '#/env' +import {IS_LIQUID_GLASS, IS_WEB} from '#/env' import {useDemoMode} from '#/storage/hooks/demo-mode' type Props = NativeStackScreenProps @@ -79,7 +79,7 @@ export function HomeScreen(props: Props) { if (preferences && pinnedFeedInfos && !isPinnedFeedsLoading) { return ( - + Date: Fri, 27 Feb 2026 03:06:31 +0000 Subject: [PATCH 15/43] Nightly source-language update --- src/locale/locales/en/messages.po | 42 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index cc2f8dda2..c3b4f86ba 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -839,7 +839,7 @@ msgid "Add a user to this list" msgstr "" #: src/components/dialogs/SwitchAccount.tsx:56 -#: src/screens/Deactivated.tsx:184 +#: src/screens/Deactivated.tsx:186 msgid "Add account" msgstr "" @@ -1913,7 +1913,7 @@ msgstr "" #: src/features/liveNow/components/GoLiveDialog.tsx:248 #: src/features/liveNow/components/GoLiveDialog.tsx:254 #: src/lib/media/picker.tsx:38 -#: src/screens/Deactivated.tsx:150 +#: src/screens/Deactivated.tsx:152 #: src/screens/Profile/Header/EditProfileDialog.tsx:219 #: src/screens/Profile/Header/EditProfileDialog.tsx:227 #: src/screens/Search/Shell.tsx:370 @@ -1938,7 +1938,7 @@ msgstr "" msgid "Cancel quote post" msgstr "" -#: src/screens/Deactivated.tsx:144 +#: src/screens/Deactivated.tsx:146 msgid "Cancel reactivation and sign out" msgstr "" @@ -3833,7 +3833,7 @@ msgstr "" msgid "Explicit sexual images." msgstr "" -#: src/Navigation.tsx:789 +#: src/Navigation.tsx:808 #: src/screens/Search/Shell.tsx:327 #: src/view/shell/desktop/LeftNav.tsx:689 #: src/view/shell/Drawer.tsx:417 @@ -5032,8 +5032,8 @@ msgstr "" msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" msgstr "" -#: src/Navigation.tsx:784 -#: src/Navigation.tsx:804 +#: src/Navigation.tsx:803 +#: src/Navigation.tsx:823 #: src/view/shell/bottom-bar/BottomBar.tsx:177 #: src/view/shell/desktop/LeftNav.tsx:671 #: src/view/shell/Drawer.tsx:443 @@ -6033,7 +6033,7 @@ msgstr "" msgid "Message options" msgstr "" -#: src/Navigation.tsx:799 +#: src/Navigation.tsx:818 msgid "Messages" msgstr "" @@ -6708,7 +6708,7 @@ msgid "Notification Sounds" msgstr "" #: src/Navigation.tsx:575 -#: src/Navigation.tsx:794 +#: src/Navigation.tsx:813 #: src/screens/Notifications/ActivityList.tsx:31 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:93 #: src/screens/Settings/NotificationSettings/index.tsx:93 @@ -7046,11 +7046,11 @@ msgstr "" msgid "Options:" msgstr "" -#: src/screens/Deactivated.tsx:192 +#: src/screens/Deactivated.tsx:194 msgid "Or, continue with another account." msgstr "" -#: src/screens/Deactivated.tsx:179 +#: src/screens/Deactivated.tsx:181 msgid "Or, sign in to one of your other accounts." msgstr "" @@ -7818,7 +7818,7 @@ msgstr "" msgid "React with {emoji}" msgstr "" -#: src/screens/Deactivated.tsx:133 +#: src/screens/Deactivated.tsx:135 msgid "Reactivate your account" msgstr "" @@ -9325,8 +9325,8 @@ msgstr "" msgid "Sign in as..." msgstr "" -#: src/screens/Deactivated.tsx:195 -#: src/screens/Deactivated.tsx:201 +#: src/screens/Deactivated.tsx:197 +#: src/screens/Deactivated.tsx:203 msgid "Sign in or create an account" msgstr "" @@ -9463,7 +9463,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:139 #: src/components/moderation/ReportDialog/index.tsx:273 -#: src/screens/Deactivated.tsx:86 +#: src/screens/Deactivated.tsx:88 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:59 #: src/view/screens/Storybook/Admonitions.tsx:56 msgid "Something went wrong, please try again" @@ -10035,7 +10035,7 @@ msgid "There was an issue fetching notifications. Tap here to try again." msgstr "" #: src/screens/Search/Explore.tsx:1025 -#: src/view/com/posts/PostFeed.tsx:758 +#: src/view/com/posts/PostFeed.tsx:764 msgid "There was an issue fetching posts. Tap here to try again." msgstr "" @@ -11293,7 +11293,7 @@ msgid "View your default post interaction settings" msgstr "" #: src/view/com/home/HomeHeaderLayout.web.tsx:57 -#: src/view/com/home/HomeHeaderLayoutMobile.tsx:72 +#: src/view/com/home/HomeHeaderLayoutMobile.tsx:75 msgid "View your feeds and explore more" msgstr "" @@ -11552,7 +11552,7 @@ msgstr "" msgid "We've confirmed your age assurance status. You can now close this dialog." msgstr "" -#: src/screens/Deactivated.tsx:117 +#: src/screens/Deactivated.tsx:119 msgid "Welcome back!" msgstr "" @@ -11708,7 +11708,7 @@ msgstr "" msgid "Yes, hide" msgstr "" -#: src/screens/Deactivated.tsx:139 +#: src/screens/Deactivated.tsx:141 msgid "Yes, reactivate my account" msgstr "" @@ -11828,7 +11828,7 @@ msgstr "" msgid "You can only select one video at a time." msgstr "" -#: src/screens/Deactivated.tsx:125 +#: src/screens/Deactivated.tsx:127 msgid "You can reactivate your account to continue logging in. Your profile and posts will be visible to other users." msgstr "" @@ -12026,7 +12026,7 @@ msgid "You need to verify your email address before you can enable email 2FA." msgstr "" #. placeholder {0}: currentAccount?.handle -#: src/screens/Deactivated.tsx:120 +#: src/screens/Deactivated.tsx:122 msgid "You previously deactivated @{0}." msgstr "" @@ -12119,7 +12119,7 @@ msgstr "" msgid "You're in line" msgstr "" -#: src/screens/Deactivated.tsx:81 +#: src/screens/Deactivated.tsx:83 #: src/screens/Settings/components/DeactivateAccountDialog.tsx:54 msgid "You're signed in with an App Password. Please sign in with your main password to continue deactivating your account." msgstr "" -- 2.51.2 From 8a1f8997fe577a19d171e6f98c620486fafd95d1 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Fri, 27 Feb 2026 03:42:31 -0800 Subject: [PATCH 16/43] Fix infinite loading spinner when changing search terms (#9950) Co-authored-by: Samuel Newman --- src/screens/Search/SearchResults.tsx | 86 +++++++++++++--------------- src/screens/Search/Shell.tsx | 85 +++++++++++++-------------- src/view/com/profile/ProfileCard.tsx | 4 +- 3 files changed, 83 insertions(+), 92 deletions(-) diff --git a/src/screens/Search/SearchResults.tsx b/src/screens/Search/SearchResults.tsx index bbf08cfd2..4543e085b 100644 --- a/src/screens/Search/SearchResults.tsx +++ b/src/screens/Search/SearchResults.tsx @@ -1,9 +1,7 @@ import {memo, useCallback, useMemo, useState} from 'react' import {ActivityIndicator, View} from 'react-native' import {type AppBskyFeedDefs} from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {urls} from '#/lib/constants' import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' @@ -27,6 +25,7 @@ import {InlineLinkText} from '#/components/Link' import {ListFooter} from '#/components/Lists' import {SearchError} from '#/components/SearchError' import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' let SearchResults = ({ query, @@ -43,14 +42,14 @@ let SearchResults = ({ headerHeight: number initialPage?: number }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const sections = useMemo(() => { if (!queryWithParams) return [] const noParams = queryWithParams === query return [ { - title: _(msg`Top`), + title: l`Top`, component: ( ), }, noParams && { - title: _(msg`Feeds`), + title: l`Feeds`, component: ( ), @@ -85,7 +84,10 @@ let SearchResults = ({ title: string component: React.ReactNode }[] - }, [_, query, queryWithParams, activeTab]) + }, [l, query, queryWithParams, activeTab]) + + // There may be fewer tabs after changing the search options. + const selectedPage = initialPage > sections.length - 1 ? 0 : initialPage return ( section.title)} {...props} /> )} - initialPage={initialPage}> + initialPage={selectedPage}> {sections.map((section, i) => ( {section.component} ))} @@ -161,15 +163,15 @@ function EmptyState({ function NoResultsText({query}: {query: string}) { const t = useTheme() - const {_} = useLingui() + const {t: l} = useLingui() return ( <> - No results found for " + No results found for “ {query} - ". + ”. {'\n\n'} @@ -177,12 +179,10 @@ function NoResultsText({query}: {query: string}) { Try a different search term, or{' '} read about how to use search filters @@ -214,7 +214,7 @@ let SearchScreenPostResults = ({ sort?: 'top' | 'latest' active: boolean }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const {currentAccount, hasSession} = useSession() const [isPTR, setIsPTR] = useState(false) const trackPostView = usePostViewTracking('SearchResults') @@ -242,7 +242,7 @@ let SearchScreenPostResults = ({ }, [setIsPTR, refetch]) const onEndReached = useCallback(() => { if (isFetching || !hasNextPage || error) return - fetchNextPage() + void fetchNextPage() }, [isFetching, error, hasNextPage, fetchNextPage]) const posts = useMemo(() => { @@ -289,19 +289,15 @@ let SearchScreenPostResults = ({ if (!hasSession) { return ( - + - + Sign in or create an account @@ -319,9 +315,7 @@ let SearchScreenPostResults = ({ return error ? ( ) : ( @@ -331,18 +325,20 @@ let SearchScreenPostResults = ({ {posts.length ? ( { + renderItem={({item}: {item: SearchResultSlice}) => { if (item.type === 'post') { return } else { return null } }} - keyExtractor={item => item.key} + keyExtractor={(item: SearchResultSlice) => item.key} refreshing={isPTR} - onRefresh={onPullToRefresh} + onRefresh={() => { + void onPullToRefresh() + }} onEndReached={onEndReached} - onItemSeen={item => { + onItemSeen={(item: SearchResultSlice) => { if (item.type === 'post') { trackPostView(item.post) } @@ -374,7 +370,7 @@ let SearchScreenUserResults = ({ query: string active: boolean }): React.ReactNode => { - const {_} = useLingui() + const {t: l} = useLingui() const {hasSession} = useSession() const [isPTR, setIsPTR] = useState(false) @@ -400,7 +396,7 @@ let SearchScreenUserResults = ({ const onEndReached = useCallback(() => { if (!hasSession) return if (isFetching || !hasNextPage || error) return - fetchNextPage() + void fetchNextPage() }, [isFetching, error, hasNextPage, fetchNextPage, hasSession]) const profiles = useMemo(() => { @@ -410,9 +406,7 @@ let SearchScreenUserResults = ({ if (error) { return ( ) @@ -423,10 +417,12 @@ let SearchScreenUserResults = ({ {profiles.length ? ( } - keyExtractor={item => item.did} + renderItem={({item}: {item: bsky.profile.AnyProfileView}) => ( + + )} + keyExtractor={(item: bsky.profile.AnyProfileView) => item.did} refreshing={isPTR} - onRefresh={onPullToRefresh} + onRefresh={() => void onPullToRefresh()} onEndReached={onEndReached} desktopFixedHeight ListFooterComponent={ @@ -465,7 +461,7 @@ let SearchScreenFeedsResults = ({ {results.length ? ( ( + renderItem={({item}: {item: AppBskyFeedDefs.GeneratorView}) => ( )} - keyExtractor={item => item.uri} + keyExtractor={(item: AppBskyFeedDefs.GeneratorView) => item.uri} desktopFixedHeight ListFooterComponent={} /> diff --git a/src/screens/Search/Shell.tsx b/src/screens/Search/Shell.tsx index 9baffb11a..4314f52f1 100644 --- a/src/screens/Search/Shell.tsx +++ b/src/screens/Search/Shell.tsx @@ -12,9 +12,7 @@ import { View, type ViewStyle, } from 'react-native' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Trans} from '@lingui/react/macro' +import {Trans, useLingui} from '@lingui/react/macro' import {useFocusEffect, useNavigation, useRoute} from '@react-navigation/native' import {useQueryClient} from '@tanstack/react-query' @@ -49,6 +47,23 @@ import {SearchLanguageDropdown} from './components/SearchLanguageDropdown' import {Explore} from './Explore' import {SearchResults} from './SearchResults' +type TabParam = 'user' | 'profile' | 'feed' | 'latest' + +// Map tab parameter to tab index +function getTabIndex(tabParam?: TabParam) { + switch (tabParam) { + case 'feed': + return 3 // Feeds tab + case 'user': + case 'profile': + return 2 // People tab + case 'latest': + return 1 // Latest tab + default: + return 0 // Top tab + } +} + export function SearchScreenShell({ queryParam, testID, @@ -69,11 +84,15 @@ export function SearchScreenShell({ const navigation = useNavigation() const route = useRoute() const textInput = useRef(null) - const {_} = useLingui() + const {t: l} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const {currentAccount} = useSession() const queryClient = useQueryClient() + // Get tab parameter from route params + const tabParam = (route.params as {q?: string; tab?: TabParam})?.tab + const [activeTab, setActiveTab] = useState(() => getTabIndex(tabParam)) + // Query terms const [searchText, setSearchText] = useState(queryParam) const {data: autocompleteData, isFetching: isAutocompleteFetching} = @@ -96,7 +115,7 @@ export function SearchScreenShell({ }) const updateSearchHistory = useCallback( - async (item: string) => { + (item: string) => { if (!item) return const newSearchHistory = [ item, @@ -108,7 +127,7 @@ export function SearchScreenShell({ ) const updateProfileHistory = useCallback( - async (item: bsky.profile.AnyProfileView) => { + (item: bsky.profile.AnyProfileView) => { const newAccountHistory = [ item.did, ...accountHistory.filter(p => p !== item.did), @@ -119,13 +138,13 @@ export function SearchScreenShell({ ) const deleteSearchHistoryItem = useCallback( - async (item: string) => { + (item: string) => { setTermHistory(termHistory.filter(search => search !== item)) }, [termHistory, setTermHistory], ) const deleteProfileHistoryItem = useCallback( - async (item: bsky.profile.AnyProfileView) => { + (item: bsky.profile.AnyProfileView) => { setAccountHistory(accountHistory.filter(p => p !== item.did)) }, [accountHistory, setAccountHistory], @@ -162,7 +181,7 @@ export function SearchScreenShell({ textInput.current?.focus() }, []) - const onChangeText = useCallback(async (text: string) => { + const onChangeText = useCallback((text: string) => { scrollToTopWeb() setSearchText(text) }, []) @@ -277,7 +296,7 @@ export function SearchScreenShell({ }, [setShowAutocomplete]) const focusSearchInput = useCallback( - (tab?: 'user' | 'profile' | 'feed') => { + (tab?: TabParam) => { textInput.current?.focus() // If a tab is specified, set the tab parameter @@ -350,15 +369,14 @@ export function SearchScreenShell({ onClearText={onPressClearQuery} onSubmitEditing={onSubmit} placeholder={ - inputPlaceholder ?? - _(msg`Search for posts, users, or feeds`) + inputPlaceholder ?? l`Search for posts, users, or feeds` } hitSlop={{...HITSLOP_20, top: 0}} /> {showAutocomplete && ( - + -- 2.51.2 From 7c9f05a2af7b3b1ec6e8a750364d27c51525d567 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 27 Feb 2026 22:19:04 +0000 Subject: [PATCH 19/43] ContextMenu - return item to the right location if keyboard hides (#9963) --- src/components/ContextMenu/context.tsx | 14 ++-- src/components/ContextMenu/index.tsx | 110 +++++++++++++++++++------ src/components/ContextMenu/types.ts | 1 + 3 files changed, 93 insertions(+), 32 deletions(-) diff --git a/src/components/ContextMenu/context.tsx b/src/components/ContextMenu/context.tsx index d09d3e452..908a8e352 100644 --- a/src/components/ContextMenu/context.tsx +++ b/src/components/ContextMenu/context.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import {createContext, useContext} from 'react' import { type ContextType, @@ -6,17 +6,17 @@ import { type MenuContextType, } from '#/components/ContextMenu/types' -export const Context = React.createContext(null) +export const Context = createContext(null) Context.displayName = 'ContextMenuContext' -export const MenuContext = React.createContext(null) +export const MenuContext = createContext(null) MenuContext.displayName = 'ContextMenuMenuContext' -export const ItemContext = React.createContext(null) +export const ItemContext = createContext(null) ItemContext.displayName = 'ContextMenuItemContext' export function useContextMenuContext() { - const context = React.useContext(Context) + const context = useContext(Context) if (!context) { throw new Error( @@ -28,7 +28,7 @@ export function useContextMenuContext() { } export function useContextMenuMenuContext() { - const context = React.useContext(MenuContext) + const context = useContext(MenuContext) if (!context) { throw new Error( @@ -40,7 +40,7 @@ export function useContextMenuMenuContext() { } export function useContextMenuItemContext() { - const context = React.useContext(ItemContext) + const context = useContext(ItemContext) if (!context) { throw new Error( diff --git a/src/components/ContextMenu/index.tsx b/src/components/ContextMenu/index.tsx index 40f2cb534..8481ee6b9 100644 --- a/src/components/ContextMenu/index.tsx +++ b/src/components/ContextMenu/index.tsx @@ -23,6 +23,7 @@ import { type GestureUpdateEvent, type PanGestureHandlerEventPayload, } from 'react-native-gesture-handler' +import {KeyboardEvents} from 'react-native-keyboard-controller' import Animated, { clamp, interpolate, @@ -35,6 +36,7 @@ import Animated, { type WithSpringConfig, } from 'react-native-reanimated' import { + type EdgeInsets, useSafeAreaFrame, useSafeAreaInsets, } from 'react-native-safe-area-context' @@ -81,9 +83,9 @@ export { const {Provider: PortalProvider, Outlet, Portal} = createPortalGroup() const SPRING_IN: WithSpringConfig = { - mass: IS_IOS ? 1.25 : 0.75, - damping: 50, - stiffness: 1100, + mass: 0.75, + damping: 300, + stiffness: 1200, restDisplacementThreshold: 0.01, } @@ -110,6 +112,7 @@ export function Root({children}: {children: React.ReactNode}) { const playHaptic = useHaptics() const [mode, setMode] = useState<'full' | 'auxiliary-only'>('full') const [measurement, setMeasurement] = useState(null) + const returnLocationSV = useSharedValue<{x: number; y: number} | null>(null) const animationSV = useSharedValue(0) const translationSV = useSharedValue(0) const isFocused = useIsFocused() @@ -142,6 +145,7 @@ export function Root({children}: {children: React.ReactNode}) { ({ isOpen: !!measurement && isFocused, measurement, + returnLocationSV, animationSV, translationSV, mode, @@ -149,6 +153,8 @@ export function Root({children}: {children: React.ReactNode}) { setMeasurement(evt) setMode(mode) animationSV.set(withSpring(1, SPRING_IN)) + // reset return location + returnLocationSV.set(null) }, close: () => { animationSV.set( @@ -156,6 +162,9 @@ export function Root({children}: {children: React.ReactNode}) { if (finished) { hoverablesSV.set({}) translationSV.set(0) + // note: return location has to be reset on open, + // rather than on close, otherwise there's a flicker + // where the reanimated update is faster than the react render runOnJS(onCompletedClose)() } }), @@ -194,6 +203,7 @@ export function Root({children}: {children: React.ReactNode}) { }) satisfies ContextType, [ measurement, + returnLocationSV, setMeasurement, onCompletedClose, isFocused, @@ -225,7 +235,7 @@ export function Root({children}: {children: React.ReactNode}) { export function Trigger({children, label, contentLabel, style}: TriggerProps) { const context = useContextMenuContext() const playHaptic = useHaptics() - const {top: topInset} = useSafeAreaInsets() + const insets = useSafeAreaInsets() const ref = useRef(null) const isFocused = useIsFocused() const [image, setImage] = useState(null) @@ -237,23 +247,8 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { const open = useNonReactiveCallback( async (mode: 'full' | 'auxiliary-only') => { playHaptic() - Keyboard.dismiss() const [measurement, capture] = await Promise.all([ - new Promise(resolve => { - ref.current?.measureInWindow((x, y, width, height) => - resolve({ - x, - y: - y + - platform({ - default: 0, - android: topInset, // not included in measurement - }), - width, - height, - }), - ) - }), + measureView(ref.current, insets), captureRef(ref, {result: 'data-uri'}).catch(err => { logger.error(err instanceof Error ? err : String(err), { message: 'Failed to capture image of context menu trigger', @@ -262,16 +257,45 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { return '' }), ]) + Keyboard.dismiss() setImage(capture) - setPendingMeasurement({measurement, mode}) + if (measurement) { + setPendingMeasurement({measurement, mode}) + } }, ) + // after keyboard hides, the position might change - set a return location + useEffect(() => { + if (context.isOpen && context.measurement) { + const hide = KeyboardEvents.addListener('keyboardDidHide', () => { + measureView(ref.current, insets) + .then(newMeasurement => { + if (!newMeasurement || !context.measurement) return + if ( + newMeasurement.x !== context.measurement.x || + newMeasurement.y !== context.measurement.y + ) { + context.returnLocationSV.set({ + x: newMeasurement.x, + y: newMeasurement.y, + }) + } + }) + .catch(() => {}) + }) + + return () => { + hide.remove() + } + } + }, [context, insets]) + const doubleTapGesture = useMemo(() => { return Gesture.Tap() .numberOfTaps(2) .hitSlop(HITSLOP_10) - .onEnd(() => open('auxiliary-only')) + .onEnd(() => void open('auxiliary-only')) .runOnJS(true) }, [open]) @@ -360,6 +384,7 @@ export function Trigger({children, label, contentLabel, style}: TriggerProps) { animation={animationSV} image={image} measurement={measurement} + returnLocation={context.returnLocationSV} onDisplay={() => { if (pendingMeasurement) { context.open( @@ -384,6 +409,7 @@ function TriggerClone({ animation, image, measurement, + returnLocation, onDisplay, label, }: { @@ -391,14 +417,29 @@ function TriggerClone({ animation: SharedValue image: string measurement: Measurement + returnLocation: SharedValue<{x: number; y: number} | null> onDisplay: () => void label: string }) { const {_} = useLingui() - const animatedStyles = useAnimatedStyle(() => ({ - transform: [{translateY: translation.get() * animation.get()}], - })) + const animatedStyles = useAnimatedStyle(() => { + const anim = animation.get() + const ret = returnLocation.get() + const returnOffsetX = ret + ? interpolate(anim, [0, 1], [ret.x - measurement.x, 0]) + : 0 + const returnOffsetY = ret + ? interpolate(anim, [0, 1], [ret.y - measurement.y, 0]) + : 0 + + return { + transform: [ + {translateX: returnOffsetX}, + {translateY: translation.get() * anim + returnOffsetY}, + ], + } + }) const handleError = useCallback( (evt: ImageErrorEventData) => { @@ -874,6 +915,25 @@ export function Divider() { ) } +function measureView(view: View | null, insets: EdgeInsets) { + if (!view) return Promise.resolve(null) + return new Promise(resolve => { + view?.measureInWindow((x, y, width, height) => + resolve({ + x, + y: + y + + platform({ + default: 0, + android: insets.top, // not included in measurement + }), + width, + height, + }), + ) + }) +} + function getHoveredHoverable( evt: | GestureStateChangeEvent diff --git a/src/components/ContextMenu/types.ts b/src/components/ContextMenu/types.ts index bda224542..7d4f3019a 100644 --- a/src/components/ContextMenu/types.ts +++ b/src/components/ContextMenu/types.ts @@ -49,6 +49,7 @@ export type ContextType = { translationSV: SharedValue mode: 'full' | 'auxiliary-only' open: (evt: Measurement, mode: 'full' | 'auxiliary-only') => void + returnLocationSV: SharedValue<{x: number; y: number} | null> close: () => void registerHoverable: ( id: string, -- 2.51.2 From 9bbcb472ef7d6001659594fedd25ee86567d2f83 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Fri, 27 Feb 2026 16:59:19 -0800 Subject: [PATCH 20/43] Use on-device translation on mobile when available (#9930) Co-authored-by: Samuel Newman --- package.json | 1 + src/analytics/metrics/types.ts | 13 + src/components/Post/Translated/index.tsx | 167 + .../PostControls/PostMenu/PostMenuItems.tsx | 38 +- src/components/Select/index.tsx | 3 +- src/components/Select/types.ts | 2 + .../dialogs/LanguageSelectDialog.tsx | 17 +- src/lib/hooks/useTranslate.ts | 16 +- src/locale/helpers.ts | 8 +- src/locale/languages.ts | 4529 +++++++++++++++-- .../components/ThreadItemAnchor.tsx | 151 +- .../components/SearchLanguageDropdown.tsx | 10 +- src/screens/Settings/LanguageSettings.tsx | 54 +- src/translation/index.tsx | 188 + src/translation/index.web.tsx | 43 + src/view/com/home/HomeHeaderLayout.web.tsx | 3 +- yarn.lock | 5 + 17 files changed, 4630 insertions(+), 618 deletions(-) create mode 100644 src/components/Post/Translated/index.tsx create mode 100644 src/translation/index.tsx create mode 100644 src/translation/index.web.tsx diff --git a/package.json b/package.json index a21ca867e..f01c01b7a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "@braintree/sanitize-url": "^6.0.2", "@bsky.app/alf": "^0.1.7", "@bsky.app/expo-image-crop-tool": "^0.5.0", + "@bsky.app/expo-translate-text": "^0.2.4", "@bsky.app/react-native-mmkv": "2.12.5", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@emoji-mart/react": "^1.1.1", diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 8f14159c6..efb002f40 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -2,6 +2,8 @@ * Do not import runtime code into this file */ +import {type Platform} from 'react-native' + import {type NotificationReason} from '#/lib/hooks/useNotificationHandler' import {type FeedDescriptor} from '#/state/queries/post-feed' import {type LiveEventFeedMetricContext} from '#/features/liveEvents/types' @@ -679,6 +681,17 @@ export type Events = { targetLanguage: string textLength: number } + 'translate:result': { + method: 'on-device' | 'google-translate' | 'fallback-alert' + os: Platform['OS'] + sourceLanguage: string | null + targetLanguage: string + } + 'translate:override': { + os: Platform['OS'] + sourceLanguage: string + targetLanguage: string + } 'verification:create': {} 'verification:revoke': {} diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx new file mode 100644 index 000000000..6016cb8ee --- /dev/null +++ b/src/components/Post/Translated/index.tsx @@ -0,0 +1,167 @@ +import {useMemo} from 'react' +import {Platform, View} from 'react-native' +import {msg} from '@lingui/core/macro' +import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' + +import {codeToLanguageName, languageName} from '#/locale/helpers' +import {LANGUAGES} from '#/locale/languages' +import {useLanguagePrefs} from '#/state/preferences' +import {atoms as a, useTheme} from '#/alf' +import {Loader} from '#/components/Loader' +import * as Select from '#/components/Select' +import {Text} from '#/components/Typography' +import {useAnalytics} from '#/analytics' +import {useTranslateOnDevice} from '#/translation' + +export function TranslatedPost({ + postText, + hideLoading = false, +}: { + postText: string + hideLoading: boolean +}) { + const {translationState} = useTranslateOnDevice() + + if (translationState.status === 'loading' && !hideLoading) { + return + } + + if (translationState.status === 'success') { + return ( + + ) + } + + return null +} + +function TranslationLoading() { + const t = useTheme() + + return ( + + + + Translating… + + + ) +} + +function TranslationResult({ + postText, + sourceLanguage, + translatedText, +}: { + postText: string + sourceLanguage: string | null + translatedText: string +}) { + const t = useTheme() + const {i18n} = useLingui() + + const langName = sourceLanguage + ? codeToLanguageName(sourceLanguage, i18n.locale) + : undefined + + return ( + + + {langName ? ( + Translated from {langName} + ) : ( + Translated + )} + {sourceLanguage != null && ( + <> + + {' '} + · + {' '} + + + )} + + + {translatedText} + + + ) +} + +function TranslationLanguageSelect({ + postText, + sourceLanguage, +}: { + postText: string + sourceLanguage: string +}) { + const ax = useAnalytics() + const {_} = useLingui() + const langPrefs = useLanguagePrefs() + const {translate} = useTranslateOnDevice() + + const items = useMemo( + () => + LANGUAGES.filter( + (lang, index, self) => + !langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant + index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2) + ) + .sort( + (a, b) => + languageName(a, langPrefs.appLanguage).localeCompare( + languageName(b, langPrefs.appLanguage), + langPrefs.appLanguage, + ), // Localized sort + ) + .map(l => ({ + label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name + value: l.code2, + })), + [langPrefs], + ) + + const handleChangeTranslationLanguage = (sourceLangCode: string) => { + ax.metric('translate:override', { + os: Platform.OS, + sourceLanguage: sourceLangCode, + targetLanguage: langPrefs.primaryLanguage, + }) + void translate(postText, langPrefs.primaryLanguage, sourceLangCode) + } + + return ( + + + {({props}) => { + return ( + + Edit + + ) + }} + + ( + + + {label} + + )} + items={items} + /> + + ) +} diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 57dcaee71..13168bd2a 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -219,7 +219,7 @@ let PostMenuItems = ({ const onToggleThreadMute = () => { try { if (isThreadMuted) { - unmuteThread() + void unmuteThread() ax.metric('post:unmute', { uri: postUri, authorDid: postAuthor.did, @@ -228,7 +228,7 @@ let PostMenuItems = ({ }) Toast.show(_(msg`You will now receive notifications for this thread`)) } else { - muteThread() + void muteThread() ax.metric('post:mute', { uri: postUri, authorDid: postAuthor.did, @@ -239,7 +239,8 @@ let PostMenuItems = ({ _(msg`You will no longer receive notifications for this thread`), ) } - } catch (e: any) { + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to toggle thread mute', {message: e}) Toast.show( @@ -253,12 +254,12 @@ let PostMenuItems = ({ const onCopyPostText = () => { const str = richTextToString(richText, true) - Clipboard.setStringAsync(str) + void Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') } const onPressTranslate = () => { - translate(record.text, langPrefs.primaryLanguage) + void translate(record.text, langPrefs.primaryLanguage) if ( bsky.dangerousIsType( @@ -343,7 +344,8 @@ let PostMenuItems = ({ ? _(msg`Quote post was successfully detached`) : _(msg`Quote post was re-attached`), ) - } catch (e: any) { + } catch (err) { + const e = err as Error Toast.show( _(msg({message: 'Updating quote attachment failed', context: 'toast'})), ) @@ -380,7 +382,8 @@ let PostMenuItems = ({ ? _(msg`Reply was successfully hidden`) : _(msg({message: 'Reply visibility updated', context: 'toast'})), ) - } catch (e: any) { + } catch (err) { + const e = err as Error if (e instanceof MaxHiddenRepliesError) { Toast.show( _( @@ -409,7 +412,7 @@ let PostMenuItems = ({ const onPressPin = () => { ax.metric(isPinned ? 'post:unpin' : 'post:pin', {}) - pinPostMutate({ + void pinPostMutate({ postUri, postCid, action: isPinned ? 'unpin' : 'pin', @@ -420,7 +423,8 @@ let PostMenuItems = ({ try { await queueBlock() Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) - } catch (e: any) { + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to block account', {message: e}) Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') @@ -433,7 +437,8 @@ let PostMenuItems = ({ try { await queueUnmute() Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) - } catch (e: any) { + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to unmute account', {message: e}) Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') @@ -443,7 +448,8 @@ let PostMenuItems = ({ try { await queueMute() Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) - } catch (e: any) { + } catch (err) { + const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to mute account', {message: e}) Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') @@ -456,7 +462,7 @@ let PostMenuItems = ({ const url = `https://docs.google.com/forms/d/e/1FAIpQLSd0QPqhNFksDQf1YyOos7r1ofCLvmrKAH1lU042TaS3GAZaWQ/viewform?entry.1756031717=${toShareUrl( href, )}` - openLink(url) + void openLink(url) } const onSignIn = () => requireSignIn(() => {}) @@ -687,7 +693,7 @@ let PostMenuItems = ({ ? _(msg`Unmute account`) : _(msg`Mute account`) } - onPress={onMuteAuthor}> + onPress={() => void onMuteAuthor()}> {postAuthor.viewer?.muted ? _(msg`Unmute account`) @@ -796,7 +802,7 @@ let PostMenuItems = ({ description={_( msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`, )} - onConfirm={onToggleQuotePostAttachment} + onConfirm={() => void onToggleQuotePostAttachment()} confirmButtonCta={_(msg`Yes, detach`)} /> @@ -806,7 +812,7 @@ let PostMenuItems = ({ description={_( 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.`, )} - onConfirm={onToggleReplyVisibility} + onConfirm={() => void onToggleReplyVisibility()} confirmButtonCta={_(msg`Yes, hide`)} /> @@ -816,7 +822,7 @@ let PostMenuItems = ({ description={_( msg`Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you.`, )} - onConfirm={onBlockAuthor} + onConfirm={() => void onBlockAuthor()} confirmButtonCta={_(msg`Block`)} confirmButtonColor="negative" /> diff --git a/src/components/Select/index.tsx b/src/components/Select/index.tsx index b7c10ed89..0438e10fc 100644 --- a/src/components/Select/index.tsx +++ b/src/components/Select/index.tsx @@ -70,7 +70,7 @@ export function Root({children, value, onValueChange, disabled}: RootProps) { ) } -export function Trigger({children, label}: TriggerProps) { +export function Trigger({children, hitSlop, label}: TriggerProps) { const {control} = useSelectContext() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const { @@ -100,6 +100,7 @@ export function Trigger({children, label}: TriggerProps) { } else { return ( diff --git a/src/translation/index.tsx b/src/translation/index.tsx new file mode 100644 index 000000000..42e62ed6e --- /dev/null +++ b/src/translation/index.tsx @@ -0,0 +1,188 @@ +import React, { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react' +import {LayoutAnimation, Platform} from 'react-native' +import {getLocales} from 'expo-localization' +import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getTranslatorLink} from '#/locale/helpers' +import {logger} from '#/logger' +import {useLanguagePrefs} from '#/state/preferences' +import {useAnalytics} from '#/analytics' + +type TranslationState = + | {status: 'idle'} + | {status: 'loading'} + | { + status: 'success' + translatedText: string + sourceLanguage: TranslationTaskResult['sourceLanguage'] + targetLanguage: TranslationTaskResult['targetLanguage'] + } + +const IDLE: TranslationState = {status: 'idle'} + +/** + * Attempts on-device translation via @bsky.app/expo-translate-text. + * Uses a lazy import to avoid crashing if the native module isn't linked into + * the current build. + */ +async function attemptTranslation( + input: string, + targetLangCodeOriginal: string, + sourceLangCodeOriginal?: string, // Auto-detects if not provided +): Promise<{ + translatedText: string + targetLanguage: TranslationTaskResult['targetLanguage'] + sourceLanguage: TranslationTaskResult['sourceLanguage'] +}> { + // Note that Android only supports two-character language codes and will fail + // on other input. + // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage + let targetLangCode = + Platform.OS === 'android' + ? targetLangCodeOriginal.split('-')[0] + : targetLangCodeOriginal + const sourceLangCode = + Platform.OS === 'android' + ? sourceLangCodeOriginal?.split('-')[0] + : sourceLangCodeOriginal + + // Special cases for regional languages + if (Platform.OS !== 'android') { + const deviceLocales = getLocales() + const primaryLanguageTag = deviceLocales[0]?.languageTag + switch (targetLangCodeOriginal) { + case 'en': // en-US, en-GB + case 'es': // es-419, es-ES + case 'pt': // pt-BR, pt-PT + case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW + targetLangCode = primaryLanguageTag ?? targetLangCodeOriginal + break + } + } + + const {onTranslateTask} = + // Needed in order to type check the dynamically imported module. + // eslint-disable-next-line @typescript-eslint/consistent-type-imports + require('@bsky.app/expo-translate-text') as typeof import('@bsky.app/expo-translate-text') + const result = await onTranslateTask({ + input, + targetLangCode, + sourceLangCode, + }) + + // Since `input` is always a string, the result should always be a string. + return { + translatedText: + typeof result.translatedTexts === 'string' ? result.translatedTexts : '', + targetLanguage: result.targetLanguage, + sourceLanguage: result.sourceLanguage ?? sourceLangCode ?? null, // iOS doesn't return the source language + } +} + +const Context = createContext<{ + translationState: TranslationState + translate: ( + text: string, + targetLangCode: string, + sourceLangCode?: string, + ) => Promise + clearTranslation: () => void +}>({ + translationState: IDLE, + translate: async () => {}, + clearTranslation: () => {}, +}) +Context.displayName = 'TranslationContext' + +/** + * Native translation hook. Attempts on-device translation using Apple + * Translation (iOS 18+) or Google ML Kit (Android). + * + * Falls back to Google Translate URL if the language pack is unavailable. + * + * Web uses index.web.ts which always opens Google Translate. + */ +export function useTranslateOnDevice() { + const context = useContext(Context) + if (!context) { + throw new Error( + 'useTranslateOnDevice must be used within a TranslateOnDeviceProvider', + ) + } + return context +} + +export function Provider({children}: {children?: React.ReactNode}) { + const [translationState, setTranslationState] = + useState(IDLE) + const openLink = useOpenLink() + const ax = useAnalytics() + const {primaryLanguage} = useLanguagePrefs() + + const clearTranslation = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState(IDLE) + }, []) + + const translate = useCallback( + async ( + text: string, + targetLangCode: string = primaryLanguage, + sourceLangCode?: string, + ) => { + setTranslationState({status: 'loading'}) + try { + const result = await attemptTranslation( + text, + targetLangCode, + sourceLangCode, + ) + ax.metric('translate:result', { + method: 'on-device', + os: Platform.OS, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + }) + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState({ + status: 'success', + translatedText: result.translatedText, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + }) + } catch (e) { + logger.error('Failed to translate post on device', {safeMessage: e}) + // On-device translation failed (language pack missing or user dismissed + // the download prompt). Fall back to Google Translate. + ax.metric('translate:result', { + method: 'fallback-alert', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + setTranslationState({status: 'idle'}) + const translateUrl = getTranslatorLink( + text, + targetLangCode, + sourceLangCode, + ) + await openLink(translateUrl) + } + }, + [ax, openLink, primaryLanguage, setTranslationState], + ) + + const ctx = useMemo( + () => ({clearTranslation, translate, translationState}), + [clearTranslation, translate, translationState], + ) + + return {children} +} diff --git a/src/translation/index.web.tsx b/src/translation/index.web.tsx new file mode 100644 index 000000000..fbeb6a1b8 --- /dev/null +++ b/src/translation/index.web.tsx @@ -0,0 +1,43 @@ +import {useCallback} from 'react' +import {Platform} from 'react-native' + +import {useOpenLink} from '#/lib/hooks/useOpenLink' +import {getTranslatorLink} from '#/locale/helpers' +import {useLanguagePrefs} from '#/state/preferences' +import {useAnalytics} from '#/analytics' + +const translationState = {status: 'idle'} // No on-device translations for web. + +const clearTranslation = () => {} // no-op on web + +/** + * Web always opens Google Translate. + */ +export function useTranslateOnDevice() { + const openLink = useOpenLink() + const ax = useAnalytics() + const {primaryLanguage} = useLanguagePrefs() + + const translate = useCallback( + async ( + text: string, + targetLangCode: string = primaryLanguage, + sourceLangCode: string, + ) => { + const translateUrl = getTranslatorLink( + text, + targetLangCode, + sourceLangCode, + ) + ax.metric('translate:result', { + method: 'google-translate', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + await openLink(translateUrl) + }, + [ax, openLink, primaryLanguage], + ) + return {clearTranslation, translate, translationState} +} diff --git a/src/view/com/home/HomeHeaderLayout.web.tsx b/src/view/com/home/HomeHeaderLayout.web.tsx index a14acd6cb..944a917a7 100644 --- a/src/view/com/home/HomeHeaderLayout.web.tsx +++ b/src/view/com/home/HomeHeaderLayout.web.tsx @@ -4,6 +4,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import type React from 'react' +import {HITSLOP_10} from '#/lib/constants' import {useKawaiiMode} from '#/state/preferences/kawaii' import {useSession} from '#/state/session' import {useShellLayout} from '#/state/shell/shell-layout' @@ -53,7 +54,7 @@ function HomeHeaderLayoutDesktopAndTablet({ Date: Sat, 28 Feb 2026 02:56:49 +0000 Subject: [PATCH 21/43] Nightly source-language update --- src/locale/locales/en/messages.po | 348 ++++++++++++++++-------------- 1 file changed, 189 insertions(+), 159 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index c3b4f86ba..8070c92c9 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -458,7 +458,7 @@ msgstr "" msgid "{MAX_ALT_TEXT, plural, other {Alt text must be less than # characters.}}" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:387 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:390 msgid "{MAX_HIDDEN_REPLIES, plural, other {You can hide a maximum of # replies.}}" msgstr "" @@ -553,28 +553,28 @@ msgstr "" #. Like count display, the <0> tags enclose the number of likes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.likeCount) #. placeholder {1}: post.likeCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:492 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:503 msgid "<0>{0} {1, plural, one {like} other {likes}}" msgstr "" #. Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.quoteCount) #. placeholder {1}: post.quoteCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:474 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:485 msgid "<0>{0} {1, plural, one {quote} other {quotes}}" msgstr "" #. Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.repostCount) #. placeholder {1}: post.repostCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:454 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:465 msgid "<0>{0} {1, plural, one {repost} other {reposts}}" msgstr "" #. Save count display, the <0> tags enclose the number of saves in bold (will never be 0) #. placeholder {0}: formatPostStatCount(post.bookmarkCount) #. placeholder {1}: post.bookmarkCount -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:505 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:516 msgid "<0>{0} {1, plural, one {save} other {saves}}" msgstr "" @@ -601,7 +601,7 @@ msgid "<0>{date} at {time}" msgstr "" #: src/screens/Hashtag.tsx:239 -#: src/screens/Search/SearchResults.tsx:295 +#: src/screens/Search/SearchResults.tsx:294 msgid "<0>Sign in<1> or <2>create an account<3> <4>to search for news, sports, politics, and everything else happening on Bluesky." msgstr "" @@ -728,7 +728,7 @@ msgstr "" msgid "Account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:422 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:425 #: src/screens/Messages/components/RequestButtons.tsx:92 #: src/view/com/profile/ProfileMenu.tsx:182 msgctxt "toast" @@ -748,7 +748,7 @@ msgstr "" msgid "Account is deactivated" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:445 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:450 #: src/view/com/profile/ProfileMenu.tsx:158 msgctxt "toast" msgid "Account muted" @@ -787,7 +787,7 @@ msgctxt "toast" msgid "Account unfollowed" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:435 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:439 #: src/view/com/profile/ProfileMenu.tsx:148 msgctxt "toast" msgid "Account unmuted" @@ -848,8 +848,8 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:214 #: src/view/com/composer/photos/Gallery.tsx:171 #: src/view/com/composer/photos/Gallery.tsx:218 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:94 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:99 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:97 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:105 msgid "Add alt text" msgstr "" @@ -900,9 +900,9 @@ msgstr "" msgid "Add more details (optional)" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:199 -msgid "Add more languages..." -msgstr "" +#: src/screens/Settings/LanguageSettings.tsx:207 +msgid "Add more languages…" +msgstr "Add more languages…" #: src/components/dialogs/MutedWords.tsx:329 msgid "Add mute word with chosen settings" @@ -1063,14 +1063,14 @@ msgstr "" msgid "All friends followed!" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:248 +#: src/components/dialogs/LanguageSelectDialog.tsx:258 #: src/screens/Search/components/SearchLanguageDropdown.tsx:65 #: src/screens/Search/components/SearchLanguageDropdown.tsx:100 #: src/screens/Search/components/SearchLanguageDropdown.tsx:102 msgid "All languages" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:170 +#: src/screens/Settings/LanguageSettings.tsx:173 msgid "All languages will be shown in your feeds." msgstr "" @@ -1150,7 +1150,7 @@ msgstr "" #: src/screens/Settings/AccessibilitySettings.tsx:55 #: src/view/com/composer/GifAltText.tsx:157 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:123 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:130 #: src/view/com/composer/videos/SubtitleDialog.tsx:41 #: src/view/com/composer/videos/SubtitleDialog.tsx:59 #: src/view/com/composer/videos/SubtitleDialog.tsx:110 @@ -1168,7 +1168,7 @@ msgstr "" #. placeholder {0}: i18n.number(MAX_ALT_TEXT) #: src/view/com/composer/GifAltText.tsx:182 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:144 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:151 msgid "Alt text will be truncated. {MAX_ALT_TEXT, plural, other {Limit: {0} characters.}}" msgstr "" @@ -1178,7 +1178,7 @@ msgid "An email has been sent to {0}. It includes a confirmation code which you msgstr "" #: src/components/dialogs/GifSelect.tsx:254 -#: src/components/dialogs/LanguageSelectDialog.tsx:330 +#: src/components/dialogs/LanguageSelectDialog.tsx:348 msgid "An error has occurred" msgstr "" @@ -1434,12 +1434,12 @@ msgid "Apply Pull Request" msgstr "" #. placeholder {0}: niceDate(i18n, createdAt, 'medium') -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:683 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:728 msgid "Archived from {0}" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:652 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:692 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:699 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:737 msgid "Archived post" msgstr "" @@ -1497,8 +1497,8 @@ msgstr "" msgid "Artistic or non-erotic nudity." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:557 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:559 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:563 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:565 msgid "Assign topic for algo" msgstr "" @@ -1610,7 +1610,7 @@ msgstr "" msgid "Birthday" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:820 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:826 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:197 #: src/view/com/profile/ProfileMenu.tsx:553 msgid "Block" @@ -1618,8 +1618,8 @@ msgstr "" #: src/components/dms/ConvoMenu.tsx:262 #: src/components/dms/ConvoMenu.tsx:265 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:705 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:707 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:711 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:713 #: src/screens/Messages/components/RequestButtons.tsx:145 #: src/screens/Messages/components/RequestButtons.tsx:147 #: src/view/com/profile/ProfileMenu.tsx:459 @@ -1627,7 +1627,7 @@ msgstr "" msgid "Block account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:815 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:821 #: src/view/com/profile/ProfileMenu.tsx:536 msgid "Block Account?" msgstr "" @@ -1679,7 +1679,7 @@ msgstr "" msgid "Blocked Accounts" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:817 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:823 #: src/view/com/profile/ProfileMenu.tsx:548 msgid "Blocked accounts cannot reply in your threads, mention you, or otherwise interact with you." msgstr "" @@ -1709,7 +1709,7 @@ msgstr "" msgid "Bluesky" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:708 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:753 msgid "Bluesky cannot confirm the authenticity of the claimed date." msgstr "" @@ -1730,7 +1730,7 @@ msgstr "" msgid "Bluesky is an open network where you can choose your own provider. If you're new here, we recommend sticking with the default Bluesky Social option." msgstr "" -#: src/components/ProgressGuide/List.tsx:109 +#: src/components/ProgressGuide/List.tsx:117 msgid "Bluesky is better with friends!" msgstr "" @@ -1916,7 +1916,7 @@ msgstr "" #: src/screens/Deactivated.tsx:152 #: src/screens/Profile/Header/EditProfileDialog.tsx:219 #: src/screens/Profile/Header/EditProfileDialog.tsx:227 -#: src/screens/Search/Shell.tsx:370 +#: src/screens/Search/Shell.tsx:388 #: src/screens/Settings/AppIconSettings/index.tsx:42 #: src/screens/Settings/AppIconSettings/index.tsx:228 #: src/screens/Settings/components/ChangeHandleDialog.tsx:82 @@ -1942,7 +1942,7 @@ msgstr "" msgid "Cancel reactivation and sign out" msgstr "" -#: src/screens/Search/Shell.tsx:361 +#: src/screens/Search/Shell.tsx:379 msgid "Cancel search" msgstr "" @@ -2011,6 +2011,10 @@ msgstr "" msgid "Change report reason" msgstr "" +#: src/components/Post/Translated/index.tsx:146 +msgid "Change source language" +msgstr "Change source language" + #: src/screens/Settings/components/ChangePasswordDialog.tsx:58 msgid "Change your password" msgstr "" @@ -2126,7 +2130,7 @@ msgstr "" msgid "Choose for me" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:192 +#: src/components/dialogs/LanguageSelectDialog.tsx:202 msgid "Choose languages" msgstr "" @@ -2231,7 +2235,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:233 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:239 #: src/components/dialogs/GifSelect.tsx:270 -#: src/components/dialogs/LanguageSelectDialog.tsx:345 +#: src/components/dialogs/LanguageSelectDialog.tsx:363 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:159 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:168 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:164 @@ -2267,8 +2271,8 @@ msgstr "" msgid "Close" msgstr "" -#: src/components/Dialog/index.web.tsx:118 -#: src/components/Dialog/index.web.tsx:296 +#: src/components/Dialog/index.web.tsx:119 +#: src/components/Dialog/index.web.tsx:304 msgid "Close active dialog" msgstr "" @@ -2283,9 +2287,9 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:223 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:229 #: src/components/dialogs/GifSelect.tsx:264 -#: src/components/dialogs/LanguageSelectDialog.tsx:214 -#: src/components/dialogs/LanguageSelectDialog.tsx:308 -#: src/components/dialogs/LanguageSelectDialog.tsx:340 +#: src/components/dialogs/LanguageSelectDialog.tsx:224 +#: src/components/dialogs/LanguageSelectDialog.tsx:326 +#: src/components/dialogs/LanguageSelectDialog.tsx:358 #: src/components/verification/VerificationsDialog.tsx:138 #: src/components/verification/VerifierDialog.tsx:139 msgid "Close dialog" @@ -2509,7 +2513,7 @@ msgstr "" msgid "Content from across the network we think you might like." msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:158 +#: src/screens/Settings/LanguageSettings.tsx:161 msgid "Content languages" msgstr "" @@ -2587,7 +2591,7 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:58 #: src/components/PostControls/DiscoverDebug.tsx:36 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:257 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:258 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:74 #: src/lib/sharing.ts:24 #: src/lib/sharing.ts:40 @@ -2676,8 +2680,8 @@ msgstr "" msgid "Copy post at:// URI" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:512 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:514 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:518 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:520 msgid "Copy post text" msgstr "" @@ -2820,7 +2824,7 @@ msgstr "" #: src/components/dialogs/Signin.tsx:87 #: src/components/dialogs/Signin.tsx:89 #: src/screens/Hashtag.tsx:248 -#: src/screens/Search/SearchResults.tsx:304 +#: src/screens/Search/SearchResults.tsx:300 msgid "Create an account" msgstr "" @@ -2957,7 +2961,7 @@ msgid "Default icons" msgstr "" #: src/components/dms/MessageContextMenu.tsx:199 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:764 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:770 #: src/screens/Messages/components/ChatStatusInfo.tsx:55 #: src/screens/ProfileList/components/MoreOptionsMenu.tsx:275 #: src/screens/Settings/AppPasswords.tsx:213 @@ -3030,8 +3034,8 @@ msgstr "" msgid "Delete my account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:745 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:747 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:751 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:753 #: src/view/com/composer/Composer.tsx:1430 msgid "Delete post" msgstr "" @@ -3049,7 +3053,7 @@ msgstr "" msgid "Delete this list?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:759 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:765 msgid "Delete this post?" msgstr "" @@ -3080,16 +3084,16 @@ msgid "Description" msgstr "" #: src/view/com/composer/GifAltText.tsx:153 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:119 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:126 msgid "Descriptive alt text" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:649 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:659 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:655 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:665 msgid "Detach quote" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:795 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:801 msgid "Detach quote post?" msgstr "" @@ -3204,7 +3208,7 @@ msgstr "" msgid "Discover New Feeds" msgstr "" -#: src/components/Dialog/index.tsx:379 +#: src/components/Dialog/index.tsx:385 msgid "Dismiss" msgstr "" @@ -3216,7 +3220,7 @@ msgstr "" msgid "Dismiss error" msgstr "" -#: src/components/ProgressGuide/List.tsx:74 +#: src/components/ProgressGuide/List.tsx:82 msgid "Dismiss getting started guide" msgstr "" @@ -3295,7 +3299,7 @@ msgstr "" #: src/components/contacts/screens/ViewMatches.tsx:412 #: src/components/dialogs/BirthDateSettings.tsx:197 #: src/components/dialogs/BirthDateSettings.tsx:204 -#: src/components/dialogs/LanguageSelectDialog.tsx:313 +#: src/components/dialogs/LanguageSelectDialog.tsx:331 #: src/components/dialogs/ServerInput.tsx:241 #: src/components/dialogs/ServerInput.tsx:243 #: src/components/dms/AfterReportDialog.tsx:143 @@ -3323,7 +3327,7 @@ msgstr "" msgid "Double tap or long press the message to add a reaction" msgstr "" -#: src/components/Dialog/index.tsx:380 +#: src/components/Dialog/index.tsx:386 msgid "Double tap to close the dialog" msgstr "" @@ -3404,6 +3408,7 @@ msgstr "" msgid "Eating disorders" msgstr "" +#: src/components/Post/Translated/index.tsx:150 #: src/screens/Settings/AccountSettings.tsx:146 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:255 #: src/screens/StarterPack/StarterPackScreen.tsx:602 @@ -3433,8 +3438,8 @@ msgstr "" msgid "Edit image" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:726 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:739 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:732 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:745 msgid "Edit interaction settings" msgstr "" @@ -3738,7 +3743,7 @@ msgstr "" msgid "Error receiving captcha response." msgstr "" -#: src/screens/Search/SearchResults.tsx:150 +#: src/screens/Search/SearchResults.tsx:152 msgid "Error: {error}" msgstr "" @@ -3834,7 +3839,7 @@ msgid "Explicit sexual images." msgstr "" #: src/Navigation.tsx:808 -#: src/screens/Search/Shell.tsx:327 +#: src/screens/Search/Shell.tsx:346 #: src/view/shell/desktop/LeftNav.tsx:689 #: src/view/shell/Drawer.tsx:417 msgid "Explore" @@ -4077,7 +4082,7 @@ msgstr "" msgid "Failed to submit appeal, please try again." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:246 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:247 msgid "Failed to toggle thread mute, please try again" msgstr "" @@ -4168,8 +4173,8 @@ msgstr "" msgid "Feedback" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:300 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:324 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:301 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:325 msgctxt "toast" msgid "Feedback sent to feed operator" msgstr "" @@ -4177,7 +4182,7 @@ msgstr "" #: src/Navigation.tsx:585 #: src/screens/SavedFeeds.tsx:118 #: src/screens/SavedFeeds.tsx:314 -#: src/screens/Search/SearchResults.tsx:79 +#: src/screens/Search/SearchResults.tsx:78 #: src/screens/StarterPack/StarterPackScreen.tsx:196 #: src/view/screens/Feeds.tsx:512 #: src/view/screens/Profile.tsx:240 @@ -4280,7 +4285,7 @@ msgstr "" msgid "Find people to follow" msgstr "" -#: src/screens/Search/Shell.tsx:526 +#: src/screens/Search/Shell.tsx:521 msgid "Find posts, users, and feeds on Bluesky" msgstr "" @@ -4350,11 +4355,11 @@ msgstr "" msgid "Follow 10 accounts" msgstr "" -#: src/components/ProgressGuide/List.tsx:67 +#: src/components/ProgressGuide/List.tsx:75 msgid "Follow 10 people to get started" msgstr "" -#: src/components/ProgressGuide/List.tsx:108 +#: src/components/ProgressGuide/List.tsx:116 msgid "Follow 7 accounts" msgstr "" @@ -4909,7 +4914,7 @@ msgstr "" #: src/components/moderation/ContentHider.tsx:220 #: src/components/moderation/LabelPreference.tsx:141 #: src/components/moderation/PostHider.tsx:140 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:775 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:781 #: src/lib/moderation/useLabelBehaviorDescription.ts:18 #: src/lib/moderation/useLabelBehaviorDescription.ts:23 #: src/lib/moderation/useLabelBehaviorDescription.ts:28 @@ -4936,18 +4941,18 @@ msgstr "" msgid "Hide lists" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:606 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:612 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:618 msgid "Hide post for me" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:623 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:633 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:629 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:639 msgid "Hide reply for everyone" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:605 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:611 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:617 msgid "Hide reply for me" msgstr "" @@ -4960,15 +4965,20 @@ msgstr "" msgid "Hide this event" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:770 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:776 msgid "Hide this post?" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:770 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:805 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:776 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:811 msgid "Hide this reply?" msgstr "" +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:632 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:635 +msgid "Hide translation" +msgstr "Hide translation" + #: src/components/interstitials/Trending.tsx:115 msgid "Hide trending topics" msgstr "" @@ -5098,7 +5108,7 @@ msgstr "" msgid "If alt text is long, toggles alt text expanded state" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:216 +#: src/screens/Settings/LanguageSettings.tsx:222 msgid "If none are selected, all languages will be shown in your feeds." msgstr "" @@ -5134,7 +5144,7 @@ msgstr "" msgid "If you need to update your email, <0>click here." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:761 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:767 msgid "If you remove this post, you won't be able to recover it." msgstr "" @@ -5304,7 +5314,7 @@ msgstr "" msgid "Invalid handle. Please try a different one." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:394 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:397 msgctxt "toast" msgid "Invalid interaction settings." msgstr "" @@ -5475,7 +5485,7 @@ msgid "Last initiated just now" msgstr "" #: src/screens/Hashtag.tsx:112 -#: src/screens/Search/SearchResults.tsx:63 +#: src/screens/Search/SearchResults.tsx:62 #: src/screens/Topic.tsx:78 msgid "Latest" msgstr "" @@ -5623,7 +5633,7 @@ msgstr "" msgid "Like ({0, plural, one {# like} other {# likes}})" msgstr "" -#: src/components/ProgressGuide/List.tsx:102 +#: src/components/ProgressGuide/List.tsx:110 msgid "Like 10 posts" msgstr "" @@ -5686,7 +5696,7 @@ msgstr "" msgid "Likes of your reposts notifications" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:488 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:499 msgid "Likes on this post" msgstr "" @@ -6166,8 +6176,8 @@ msgstr "" msgid "Mute {0}" msgstr "Mute {0}" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:688 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:694 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:700 #: src/view/com/profile/ProfileMenu.tsx:438 #: src/view/com/profile/ProfileMenu.tsx:445 msgid "Mute account" @@ -6219,13 +6229,13 @@ msgstr "" msgid "Mute this word until you unmute it" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:572 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:576 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:578 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:582 msgid "Mute thread" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:586 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:588 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:592 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:594 msgid "Mute words & tags" msgstr "" @@ -6596,9 +6606,9 @@ msgstr "" msgid "No results found for \"{query}\"" msgstr "" -#: src/screens/Search/SearchResults.tsx:169 -msgid "No results found for \"<0>{query}\"." -msgstr "" +#: src/screens/Search/SearchResults.tsx:171 +msgid "No results found for “<0>{query}”." +msgstr "No results found for “<0>{query}”." #: src/screens/Search/Explore.tsx:830 msgid "No results." @@ -6757,7 +6767,7 @@ msgid "Off" msgstr "" #: src/components/dialogs/GifSelect.tsx:257 -#: src/components/dialogs/LanguageSelectDialog.tsx:333 +#: src/components/dialogs/LanguageSelectDialog.tsx:351 #: src/view/com/util/ErrorBoundary.tsx:57 msgid "Oh no!" msgstr "" @@ -6772,7 +6782,7 @@ msgid "OK" msgstr "" #: src/screens/Login/PasswordUpdatedForm.tsx:37 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:714 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:759 msgid "Okay" msgstr "" @@ -7169,7 +7179,7 @@ msgid "Pause video" msgstr "" #: src/screens/ProfileList/index.tsx:169 -#: src/screens/Search/SearchResults.tsx:73 +#: src/screens/Search/SearchResults.tsx:72 #: src/screens/StarterPack/StarterPackScreen.tsx:195 msgid "People" msgstr "" @@ -7249,8 +7259,8 @@ msgstr "" msgid "Pin to Home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:480 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:487 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:486 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:493 msgid "Pin to your profile" msgstr "" @@ -7522,7 +7532,7 @@ msgstr "" msgid "Post failed to upload. Please check your Internet connection and try again." msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:134 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:143 #: src/screens/PostThread/components/ThreadItemPost.tsx:112 #: src/screens/PostThread/components/ThreadItemTreePost.tsx:108 #: src/screens/VideoFeed/index.tsx:552 @@ -7775,11 +7785,11 @@ msgstr "" msgid "Quote post" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:344 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:345 msgid "Quote post was re-attached" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:343 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:344 msgid "Quote post was successfully detached" msgstr "" @@ -7797,7 +7807,7 @@ msgstr "" msgid "Quotes" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:470 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:481 msgid "Quotes of this post" msgstr "" @@ -7809,8 +7819,8 @@ msgstr "" msgid "Rate limit exceeded. Please try again later." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:648 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:658 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:654 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:664 msgid "Re-attach quote" msgstr "" @@ -7827,7 +7837,7 @@ msgstr "" msgid "Read {0, plural, one {# more reply} other {# more replies}}" msgstr "" -#: src/screens/Search/SearchResults.tsx:181 +#: src/screens/Search/SearchResults.tsx:182 msgctxt "english-only-resource" msgid "read about how to use search filters" msgstr "" @@ -7892,7 +7902,7 @@ msgstr "" msgid "Recent Searches" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:242 +#: src/components/dialogs/LanguageSelectDialog.tsx:252 msgid "Recently used" msgstr "" @@ -8187,12 +8197,12 @@ msgstr "" msgid "Reply sorting" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:381 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:383 msgctxt "toast" msgid "Reply visibility updated" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:380 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:382 msgid "Reply was successfully hidden" msgstr "" @@ -8235,8 +8245,8 @@ msgstr "" msgid "Report message" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:714 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:716 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:720 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:722 msgid "Report post" msgstr "" @@ -8328,7 +8338,7 @@ msgstr "" msgid "Reposts" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:450 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:461 msgid "Reposts of this post" msgstr "" @@ -8497,8 +8507,8 @@ msgstr "" #: src/view/com/composer/GifAltText.tsx:205 #: src/view/com/composer/photos/EditImageDialog.web.tsx:63 #: src/view/com/composer/photos/EditImageDialog.web.tsx:76 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:158 -#: src/view/com/composer/photos/ImageAltTextDialog.tsx:168 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:165 +#: src/view/com/composer/photos/ImageAltTextDialog.tsx:175 msgid "Save" msgstr "" @@ -8612,8 +8622,8 @@ msgstr "" #: src/components/dialogs/SearchablePeopleList.tsx:515 #: src/components/forms/SearchInput.tsx:34 #: src/components/forms/SearchInput.tsx:36 -#: src/screens/Search/Shell.tsx:327 -#: src/screens/Search/Shell.tsx:514 +#: src/screens/Search/Shell.tsx:346 +#: src/screens/Search/Shell.tsx:509 #: src/view/shell/bottom-bar/BottomBar.tsx:197 msgid "Search" msgstr "" @@ -8667,7 +8677,7 @@ msgstr "" msgid "Search for more feeds" msgstr "" -#: src/screens/Search/Shell.tsx:354 +#: src/screens/Search/Shell.tsx:372 msgid "Search for posts, users, or feeds" msgstr "" @@ -8676,12 +8686,12 @@ msgid "Search GIFs" msgstr "" #: src/screens/Hashtag.tsx:237 -#: src/screens/Search/SearchResults.tsx:293 +#: src/screens/Search/SearchResults.tsx:292 msgid "Search is currently unavailable when logged out" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:225 -#: src/components/dialogs/LanguageSelectDialog.tsx:226 +#: src/components/dialogs/LanguageSelectDialog.tsx:235 +#: src/components/dialogs/LanguageSelectDialog.tsx:236 msgid "Search languages" msgstr "" @@ -8808,7 +8818,7 @@ msgstr "" msgid "Select an emoji" msgstr "" -#: src/components/Select/index.tsx:198 +#: src/components/Select/index.tsx:199 msgid "Select an option" msgstr "" @@ -8821,8 +8831,8 @@ msgstr "" msgid "Select caption file (.vtt)" msgstr "Select caption file (.vtt)" -#: src/screens/Settings/LanguageSettings.tsx:176 -#: src/screens/Settings/LanguageSettings.tsx:214 +#: src/screens/Settings/LanguageSettings.tsx:179 +#: src/screens/Settings/LanguageSettings.tsx:220 msgid "Select content languages" msgstr "" @@ -8865,7 +8875,7 @@ msgstr "" msgid "Select language..." msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:258 +#: src/components/dialogs/LanguageSelectDialog.tsx:270 msgid "Select languages" msgstr "" @@ -8889,6 +8899,10 @@ msgstr "" msgid "Select the {emojiName} emoji as your avatar" msgstr "" +#: src/components/Post/Translated/index.tsx:156 +msgid "Select the source language" +msgstr "Select the source language" + #: src/view/com/composer/select-language/PostLanguageSelect.tsx:59 #: src/view/com/composer/select-language/PostLanguageSelect.tsx:115 msgid "Select up to 3 languages used in this post" @@ -8902,7 +8916,7 @@ msgstr "" msgid "Select which language to use for the app's user interface." msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:162 +#: src/screens/Settings/LanguageSettings.tsx:165 msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." msgstr "" @@ -9202,8 +9216,8 @@ msgstr "" msgid "Show customization options" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:543 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:545 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:549 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:551 msgid "Show less like this" msgstr "" @@ -9224,8 +9238,8 @@ msgstr "" msgid "Show More" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:535 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:537 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:541 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:543 msgid "Show more like this" msgstr "" @@ -9251,8 +9265,8 @@ msgstr "" msgid "Show replies as" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:622 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:632 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:628 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:638 msgid "Show reply for everyone" msgstr "" @@ -9279,7 +9293,7 @@ msgstr "" msgid "Show when you’re live" msgstr "" -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:654 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:700 msgid "Shows information about when this post was created" msgstr "" @@ -9302,7 +9316,7 @@ msgstr "" #: src/screens/Login/LoginForm.tsx:179 #: src/screens/Login/LoginForm.tsx:350 #: src/screens/Login/LoginForm.tsx:356 -#: src/screens/Search/SearchResults.tsx:297 +#: src/screens/Search/SearchResults.tsx:295 #: src/view/com/auth/SplashScreen.tsx:118 #: src/view/com/auth/SplashScreen.tsx:124 #: src/view/com/auth/SplashScreen.web.tsx:128 @@ -9342,8 +9356,8 @@ msgstr "" msgid "Sign in to Bluesky or create a new account" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:521 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:523 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:527 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:529 msgid "Sign in to view post" msgstr "" @@ -9800,7 +9814,7 @@ msgstr "" msgid "Task complete - 10 likes!" msgstr "" -#: src/components/ProgressGuide/List.tsx:103 +#: src/components/ProgressGuide/List.tsx:111 msgid "Teach our algorithm what you like" msgstr "" @@ -9975,7 +9989,7 @@ msgstr "" msgid "The starter pack that you are trying to view is invalid. You may delete this starter pack instead." msgstr "" -#: src/components/ContextMenu/index.tsx:434 +#: src/components/ContextMenu/index.tsx:475 msgid "The subject of the context menu" msgstr "" @@ -10067,9 +10081,9 @@ msgid "There was an issue updating your feeds, please check your internet connec msgstr "" #. placeholder {0}: e.toString() -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:426 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:439 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:449 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:430 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:444 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:455 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:117 #: src/screens/PostThread/components/ThreadItemAnchorFollowButton.tsx:128 #: src/screens/Profile/Header/ProfileHeaderStandard.tsx:90 @@ -10099,7 +10113,7 @@ msgid "There was an issue. Please check your internet connection and try again." msgstr "" #: src/components/dialogs/GifSelect.tsx:259 -#: src/components/dialogs/LanguageSelectDialog.tsx:335 +#: src/components/dialogs/LanguageSelectDialog.tsx:353 #: src/view/com/util/ErrorBoundary.tsx:59 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" msgstr "" @@ -10290,7 +10304,7 @@ msgstr "" #. placeholder {0}: niceDate(i18n, createdAt) #. placeholder {1}: niceDate(i18n, indexedAt) -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:695 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:740 msgid "This post claims to have been created on <0>{0}, but was first seen by Bluesky on <1>{1}." msgstr "" @@ -10306,7 +10320,7 @@ msgstr "" msgid "This post was deleted by its author" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:772 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:778 msgid "This post will be hidden from feeds and threads. This cannot be undone." msgstr "" @@ -10318,7 +10332,7 @@ msgstr "" msgid "This profile is only visible to logged-in users. It won't be visible to people who aren't signed in." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:807 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:813 msgid "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." msgstr "" @@ -10395,7 +10409,7 @@ msgstr "" msgid "This will remove @{0} from the quick access list." msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:797 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:803 msgid "This will remove your post from this quote post for all users, and replace it with a placeholder." msgstr "" @@ -10474,7 +10488,7 @@ msgid "Too many contacts - you've exceeded the number of contacts you can import msgstr "" #: src/screens/Hashtag.tsx:101 -#: src/screens/Search/SearchResults.tsx:53 +#: src/screens/Search/SearchResults.tsx:52 #: src/screens/Topic.tsx:72 msgid "Top" msgstr "" @@ -10492,13 +10506,26 @@ msgstr "" #: src/components/dms/MessageContextMenu.tsx:136 #: src/components/dms/MessageContextMenu.tsx:138 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:504 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:506 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:618 -#: src/screens/PostThread/components/ThreadItemAnchor.tsx:621 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:510 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:512 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:640 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:643 msgid "Translate" msgstr "" +#: src/components/Post/Translated/index.tsx:78 +msgid "Translated" +msgstr "Translated" + +#: src/components/Post/Translated/index.tsx:76 +msgid "Translated from {langName}" +msgstr "Translated from {langName}" + +#: src/components/Post/Translated/index.tsx:50 +#: src/screens/PostThread/components/ThreadItemAnchor.tsx:626 +msgid "Translating…" +msgstr "Translating…" + #: src/screens/Settings/ThreadPreferences.tsx:87 #: src/screens/Settings/ThreadPreferences.tsx:92 msgid "Tree view" @@ -10524,7 +10551,7 @@ msgstr "" msgid "Trust emerges from relationships, communities, and shared context, so we’re also enabling <0>trusted verifiers: organizations that can directly issue verification." msgstr "" -#: src/screens/Search/SearchResults.tsx:177 +#: src/screens/Search/SearchResults.tsx:179 msgctxt "english-only-resource" msgid "Try a different search term, or <0>read about how to use search filters." msgstr "" @@ -10724,8 +10751,8 @@ msgstr "" msgid "Unmute {0}" msgstr "Unmute {0}" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:687 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:693 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:699 #: src/view/com/profile/ProfileMenu.tsx:437 #: src/view/com/profile/ProfileMenu.tsx:443 msgid "Unmute account" @@ -10740,8 +10767,8 @@ msgstr "" msgid "Unmute list" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:572 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:576 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:578 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:582 msgid "Unmute thread" msgstr "" @@ -10769,8 +10796,8 @@ msgstr "" msgid "Unpin from home" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:479 -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:486 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:485 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:492 msgid "Unpin from profile" msgstr "" @@ -10843,12 +10870,12 @@ msgstr "" msgid "Update your email" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:348 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:350 msgctxt "toast" msgid "Updating quote attachment failed" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:399 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:402 msgctxt "toast" msgid "Updating reply visibility failed" msgstr "" @@ -11292,7 +11319,7 @@ msgstr "" msgid "View your default post interaction settings" msgstr "" -#: src/view/com/home/HomeHeaderLayout.web.tsx:57 +#: src/view/com/home/HomeHeaderLayout.web.tsx:58 #: src/view/com/home/HomeHeaderLayoutMobile.tsx:75 msgid "View your feeds and explore more" msgstr "" @@ -11509,11 +11536,14 @@ msgstr "" msgid "We're sorry, but we weren't able to load your muted words at this time. Please try again." msgstr "" -#: src/screens/Search/SearchResults.tsx:323 -#: src/screens/Search/SearchResults.tsx:414 +#: src/screens/Search/SearchResults.tsx:318 msgid "We're sorry, but your search could not be completed. Please try again in a few minutes." msgstr "" +#: src/screens/Search/SearchResults.tsx:409 +msgid "We’re sorry, but your search could not be completed. Please try again in a few minutes." +msgstr "We’re sorry, but your search could not be completed. Please try again in a few minutes." + #: src/components/ageAssurance/AgeRestrictedScreen.tsx:62 msgid "We're sorry, you cannot access this screen at this time." msgstr "" @@ -11700,11 +11730,11 @@ msgstr "" msgid "Yes, delete this starter pack" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:800 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:806 msgid "Yes, detach" msgstr "" -#: src/components/PostControls/PostMenu/PostMenuItems.tsx:810 +#: src/components/PostControls/PostMenu/PostMenuItems.tsx:816 msgid "Yes, hide" msgstr "" -- 2.51.2 From 8e2a5abfff38ca0830d7358153e82a354d6c7baf Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:53:42 -0800 Subject: [PATCH 22/43] Fix crash with translate link on web (#9972) --- src/translation/index.tsx | 31 +++++++++++++++++-------- src/translation/index.web.tsx | 43 ----------------------------------- 2 files changed, 21 insertions(+), 53 deletions(-) delete mode 100644 src/translation/index.web.tsx diff --git a/src/translation/index.tsx b/src/translation/index.tsx index 42e62ed6e..7c5f05f33 100644 --- a/src/translation/index.tsx +++ b/src/translation/index.tsx @@ -14,6 +14,7 @@ import {getTranslatorLink} from '#/locale/helpers' import {logger} from '#/logger' import {useLanguagePrefs} from '#/state/preferences' import {useAnalytics} from '#/analytics' +import {IS_WEB} from '#/env' type TranslationState = | {status: 'idle'} @@ -119,7 +120,7 @@ export function useTranslateOnDevice() { return context } -export function Provider({children}: {children?: React.ReactNode}) { +export function Provider({children}: React.PropsWithChildren) { const [translationState, setTranslationState] = useState(IDLE) const openLink = useOpenLink() @@ -158,15 +159,25 @@ export function Provider({children}: {children?: React.ReactNode}) { targetLanguage: result.targetLanguage, }) } catch (e) { - logger.error('Failed to translate post on device', {safeMessage: e}) - // On-device translation failed (language pack missing or user dismissed - // the download prompt). Fall back to Google Translate. - ax.metric('translate:result', { - method: 'fallback-alert', - os: Platform.OS, - sourceLanguage: sourceLangCode ?? null, - targetLanguage: targetLangCode, - }) + if (IS_WEB) { + // Web always opens Google Translate. + ax.metric('translate:result', { + method: 'google-translate', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + } else { + logger.error('Failed to translate post on device', {safeMessage: e}) + // On-device translation failed (language pack missing or user dismissed + // the download prompt). Fall back to Google Translate. + ax.metric('translate:result', { + method: 'fallback-alert', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + } setTranslationState({status: 'idle'}) const translateUrl = getTranslatorLink( text, diff --git a/src/translation/index.web.tsx b/src/translation/index.web.tsx deleted file mode 100644 index fbeb6a1b8..000000000 --- a/src/translation/index.web.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import {useCallback} from 'react' -import {Platform} from 'react-native' - -import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {getTranslatorLink} from '#/locale/helpers' -import {useLanguagePrefs} from '#/state/preferences' -import {useAnalytics} from '#/analytics' - -const translationState = {status: 'idle'} // No on-device translations for web. - -const clearTranslation = () => {} // no-op on web - -/** - * Web always opens Google Translate. - */ -export function useTranslateOnDevice() { - const openLink = useOpenLink() - const ax = useAnalytics() - const {primaryLanguage} = useLanguagePrefs() - - const translate = useCallback( - async ( - text: string, - targetLangCode: string = primaryLanguage, - sourceLangCode: string, - ) => { - const translateUrl = getTranslatorLink( - text, - targetLangCode, - sourceLangCode, - ) - ax.metric('translate:result', { - method: 'google-translate', - os: Platform.OS, - sourceLanguage: sourceLangCode ?? null, - targetLanguage: targetLangCode, - }) - await openLink(translateUrl) - }, - [ax, openLink, primaryLanguage], - ) - return {clearTranslation, translate, translationState} -} -- 2.51.2 From 9746dd8e4cec4c8597f601e441c7c7d602925c05 Mon Sep 17 00:00:00 2001 From: smileyhead Date: Mon, 2 Mar 2026 14:14:07 +0100 Subject: [PATCH 23/43] Make ALT labels translatable (#9976) --- src/components/images/AutoSizedImage.tsx | 3 ++- src/components/images/Gallery.tsx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/images/AutoSizedImage.tsx b/src/components/images/AutoSizedImage.tsx index 74b923d3c..3d4856ada 100644 --- a/src/components/images/AutoSizedImage.tsx +++ b/src/components/images/AutoSizedImage.tsx @@ -9,6 +9,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {utils} from '@bsky.app/alf' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' import {type Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' @@ -186,7 +187,7 @@ export function AutoSizedImage({ ], ]}> - ALT + ALT )} diff --git a/src/components/images/Gallery.tsx b/src/components/images/Gallery.tsx index deefc6970..b809a783f 100644 --- a/src/components/images/Gallery.tsx +++ b/src/components/images/Gallery.tsx @@ -5,6 +5,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {utils} from '@bsky.app/alf' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {Trans} from '@lingui/react/macro' import {type Dimensions} from '#/lib/media/types' import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' @@ -116,7 +117,7 @@ export function GalleryItem({ ]}> - ALT + ALT
) : null} -- 2.51.2 From 9c29b2867a7e140ae444982799dbe7a99604330c Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Mon, 2 Mar 2026 09:09:21 -0500 Subject: [PATCH 24/43] [APP-1882] fix email not updating in session state after email change (#9953) --- .../EmailDialog/data/useConfirmEmail.ts | 6 +++--- .../EmailDialog/data/useManageEmail2FA.ts | 6 +++--- .../EmailDialog/data/useUpdateEmail.ts | 20 +++++++++++++++---- src/state/session/index.tsx | 1 + src/state/session/reducer.ts | 7 ++++++- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts index 67466be92..c767697f5 100644 --- a/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useConfirmEmail.ts @@ -1,6 +1,6 @@ import {useMutation} from '@tanstack/react-query' -import {useAgent, useSession} from '#/state/session' +import {useAgent, useSession, useSessionApi} from '#/state/session' export function useConfirmEmail({ onSuccess, @@ -8,6 +8,7 @@ export function useConfirmEmail({ }: {onSuccess?: () => void; onError?: () => void} = {}) { const agent = useAgent() const {currentAccount} = useSession() + const {partialRefreshSession} = useSessionApi() return useMutation({ mutationFn: async ({token}: {token: string}) => { @@ -19,8 +20,7 @@ export function useConfirmEmail({ email: currentAccount.email.trim(), token: token.trim(), }) - // will update session state at root of app - await agent.resumeSession(agent.session!) + await partialRefreshSession() }, onSuccess, onError, diff --git a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts index 358bf8654..4c1420847 100644 --- a/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts +++ b/src/components/dialogs/EmailDialog/data/useManageEmail2FA.ts @@ -1,10 +1,11 @@ import {useMutation} from '@tanstack/react-query' -import {useAgent, useSession} from '#/state/session' +import {useAgent, useSession, useSessionApi} from '#/state/session' export function useManageEmail2FA() { const agent = useAgent() const {currentAccount} = useSession() + const {partialRefreshSession} = useSessionApi() return useMutation({ mutationFn: async ({ @@ -22,8 +23,7 @@ export function useManageEmail2FA() { emailAuthFactor: enabled, token, }) - // will update session state at root of app - await agent.resumeSession(agent.session!) + await partialRefreshSession() }, }) } diff --git a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts index 2ec1eb6dc..1f81801a7 100644 --- a/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts +++ b/src/components/dialogs/EmailDialog/data/useUpdateEmail.ts @@ -1,19 +1,21 @@ import {useMutation} from '@tanstack/react-query' -import {useAgent} from '#/state/session' +import {useAgent, useSessionApi} from '#/state/session' import {useRequestEmailUpdate} from '#/components/dialogs/EmailDialog/data/useRequestEmailUpdate' async function updateEmailAndRefreshSession( agent: ReturnType, + partialRefreshSession: () => Promise, email: string, token?: string, ) { await agent.com.atproto.server.updateEmail({email: email.trim(), token}) - await agent.resumeSession(agent.session!) + await partialRefreshSession() } export function useUpdateEmail() { const agent = useAgent() + const {partialRefreshSession} = useSessionApi() const {mutateAsync: requestEmailUpdate} = useRequestEmailUpdate() return useMutation< @@ -23,7 +25,12 @@ export function useUpdateEmail() { >({ mutationFn: async ({email, token}: {email: string; token?: string}) => { if (token) { - await updateEmailAndRefreshSession(agent, email, token) + await updateEmailAndRefreshSession( + agent, + partialRefreshSession, + email, + token, + ) return { status: 'success', } @@ -34,7 +41,12 @@ export function useUpdateEmail() { status: 'tokenRequired', } } else { - await updateEmailAndRefreshSession(agent, email, token) + await updateEmailAndRefreshSession( + agent, + partialRefreshSession, + email, + token, + ) return { status: 'success', } diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index e63e180a4..fc08f70d7 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -284,6 +284,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { type: 'partial-refresh-session', accountDid: agent.session!.did, patch: { + email: data.email, emailConfirmed: data.emailConfirmed, emailAuthFactor: data.emailAuthFactor, }, diff --git a/src/state/session/reducer.ts b/src/state/session/reducer.ts index d22dd4a02..79fffb6c5 100644 --- a/src/state/session/reducer.ts +++ b/src/state/session/reducer.ts @@ -58,7 +58,10 @@ export type Action = | { type: 'partial-refresh-session' accountDid: string - patch: Pick + patch: Pick< + SessionAccount, + 'email' | 'emailConfirmed' | 'emailAuthFactor' + > } function createPublicAgentState(): AgentState { @@ -239,6 +242,7 @@ let reducer = (state: State, action: Action): State => { * Only mutating values that are safe. Be very careful with this. */ if (agent.session) { + agent.session.email = patch.email ?? agent.session.email agent.session.emailConfirmed = patch.emailConfirmed ?? agent.session.emailConfirmed agent.session.emailAuthFactor = @@ -255,6 +259,7 @@ let reducer = (state: State, action: Action): State => { if (a.did === accountDid) { return { ...a, + email: patch.email ?? a.email, emailConfirmed: patch.emailConfirmed ?? a.emailConfirmed, emailAuthFactor: patch.emailAuthFactor ?? a.emailAuthFactor, } -- 2.51.2 From 5ee667f307bc459ba53cdaabdad00a0ea1ee6846 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Mar 2026 18:50:00 +0000 Subject: [PATCH 25/43] Force relayout to fix stuck header blur (#9979) --- src/lib/hooks/useMinimalShellTransform.ts | 33 +++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/src/lib/hooks/useMinimalShellTransform.ts b/src/lib/hooks/useMinimalShellTransform.ts index b24042977..fc3bb9c9f 100644 --- a/src/lib/hooks/useMinimalShellTransform.ts +++ b/src/lib/hooks/useMinimalShellTransform.ts @@ -16,16 +16,39 @@ export function useMinimalShellHeaderTransform() { const headerTransform = useAnimatedStyle(() => { const headerModeValue = headerMode.get() + const hHeight = headerHeight.get() + + if (IS_LIQUID_GLASS) { + // bit of a hackfix, but: the header can get affected by scrollEdgeEffects + // when animating from closed to open. workaround is to trigger a relayout + // by offsetting the top position. the actual value doesn't matter, and we + // simultaneously offset it using the translate transform. + // I think a cleaner way to do it would be to use UIScrollEdgeElementContainerInteraction + // manually or something like that, because this kinda sucks -sfn + const relayoutingOffset = headerModeValue === 0 ? 1 : 0 + return { + top: relayoutingOffset, + pointerEvents: headerModeValue === 0 ? 'auto' : 'none', + opacity: Math.pow(1 - headerModeValue, 2), + transform: [ + { + translateY: + interpolate( + headerModeValue, + [0, 1], + [0, headerPinnedHeight - hHeight], + ) - relayoutingOffset, + }, + ], + } + } + return { pointerEvents: headerModeValue === 0 ? 'auto' : 'none', opacity: Math.pow(1 - headerModeValue, 2), transform: [ { - translateY: interpolate( - headerModeValue, - [0, 1], - [0, headerPinnedHeight - headerHeight.get()], - ), + translateY: interpolate(headerModeValue, [0, 1], [0, -hHeight]), }, ], } -- 2.51.2 From f614bf650d5fe0c524d9205ce7184d2fc2019daa Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Mar 2026 19:01:09 +0000 Subject: [PATCH 26/43] Translation followups (#9980) --- src/components/Post/Translated/index.tsx | 2 +- .../dialogs/LanguageSelectDialog.tsx | 14 +- src/locale/helpers.ts | 47 +- src/locale/languages.ts | 638 ++---------------- src/screens/Settings/LanguageSettings.tsx | 52 +- 5 files changed, 130 insertions(+), 623 deletions(-) diff --git a/src/components/Post/Translated/index.tsx b/src/components/Post/Translated/index.tsx index 6016cb8ee..68176577a 100644 --- a/src/components/Post/Translated/index.tsx +++ b/src/components/Post/Translated/index.tsx @@ -147,7 +147,7 @@ function TranslationLanguageSelect({ {({props}) => { return ( - Edit + Change ) }} diff --git a/src/components/dialogs/LanguageSelectDialog.tsx b/src/components/dialogs/LanguageSelectDialog.tsx index 2179fdf2e..ca13c33b7 100644 --- a/src/components/dialogs/LanguageSelectDialog.tsx +++ b/src/components/dialogs/LanguageSelectDialog.tsx @@ -5,6 +5,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {languageName} from '#/locale/helpers' import {type Language, LANGUAGES, LANGUAGES_MAP_CODE2} from '#/locale/languages' import {useLanguagePrefs} from '#/state/preferences/languages' import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' @@ -139,8 +140,11 @@ export function DialogInner({ const recentLanguages = mapCode2List(recentLanguagesCode2) // NOTE(@elijaharita): helper functions + const searchLower = search.toLowerCase() const matchesSearch = (lang: Language) => - lang.name.toLowerCase().includes(search.toLowerCase()) + languageName(lang, langPrefs.appLanguage) + .toLowerCase() + .includes(searchLower) || lang.name.toLowerCase().includes(searchLower) const isChecked = (lang: Language) => checkedLanguagesCode2.includes(lang.code2) const isInRecents = (lang: Language) => @@ -182,6 +186,7 @@ export function DialogInner({ search, langPrefs.postLanguageHistory, checkedLanguagesCode2, + langPrefs.appLanguage, ]) const listHeader = ( @@ -297,6 +302,7 @@ export function DialogInner({ ) } const lang = item.lang + const name = languageName(lang, langPrefs.appLanguage) const isLastItem = index === numItems - 1 @@ -304,7 +310,7 @@ export function DialogInner({ - - {lang.name} - + {name} ) diff --git a/src/locale/helpers.ts b/src/locale/helpers.ts index 8ae79f33b..eb384dfec 100644 --- a/src/locale/helpers.ts +++ b/src/locale/helpers.ts @@ -32,21 +32,27 @@ export function code3ToCode2Strict(lang: string): string | undefined { return undefined } -function getLocalizedLanguage( - langCode: string, - appLang: string, -): string | undefined { - try { - const allNames = new Intl.DisplayNames([appLang], { +const displayNamesCache = new Map() + +function getDisplayNames(appLang: string): Intl.DisplayNames { + let cached = displayNamesCache.get(appLang) + if (!cached) { + cached = new Intl.DisplayNames([appLang], { type: 'language', fallback: 'none', languageDisplay: 'standard', }) - const translatedName = allNames.of(langCode) + displayNamesCache.set(appLang, cached) + } + return cached +} - if (translatedName) { - return translatedName - } +function getLocalizedLanguage( + langCode: string, + appLang: string, +): string | undefined { + try { + return getDisplayNames(appLang).of(langCode) || undefined } catch (e) { // ignore RangeError from Intl.DisplayNames APIs if (!(e instanceof RangeError)) { @@ -308,17 +314,26 @@ export function regionName(countryCode: string, appLang: string): string { return countryCode } +const regionNamesCache = new Map() + +function getRegionNames(appLang: string): Intl.DisplayNames { + let cached = regionNamesCache.get(appLang) + if (!cached) { + cached = new Intl.DisplayNames([appLang], { + type: 'region', + fallback: 'none', + }) + regionNamesCache.set(appLang, cached) + } + return cached +} + function getLocalizedRegionName( countryCode: string, appLang: string, ): string | undefined { try { - const allNames = new Intl.DisplayNames([appLang], { - type: 'region', - fallback: 'none', - }) - - return allNames.of(countryCode) + return getRegionNames(appLang).of(countryCode) } catch (err) { console.warn('Error getting localized region name:', err) return undefined diff --git a/src/locale/languages.ts b/src/locale/languages.ts index 17a98f1db..9d8008f01 100644 --- a/src/locale/languages.ts +++ b/src/locale/languages.ts @@ -2,7 +2,6 @@ export interface Language { code3: string code2: string name: string - en: string android: boolean ios: boolean } @@ -111,7 +110,6 @@ export const LANGUAGES: Language[] = [ code3: 'aar', code2: 'aa', name: 'Afar', - en: 'Afar', android: false, ios: false, }, @@ -119,7 +117,6 @@ export const LANGUAGES: Language[] = [ code3: 'abk', code2: 'ab', name: 'Abkhazian', - en: 'Abkhazian', android: false, ios: false, }, @@ -127,7 +124,6 @@ export const LANGUAGES: Language[] = [ code3: 'ace', code2: '', name: 'Achinese', - en: 'Achinese', android: false, ios: false, }, @@ -135,7 +131,6 @@ export const LANGUAGES: Language[] = [ code3: 'ach', code2: '', name: 'Acoli', - en: 'Acoli', android: false, ios: false, }, @@ -143,7 +138,6 @@ export const LANGUAGES: Language[] = [ code3: 'ada', code2: '', name: 'Adangme', - en: 'Adangme', android: false, ios: false, }, @@ -151,7 +145,6 @@ export const LANGUAGES: Language[] = [ code3: 'ady', code2: '', name: 'Adyghe; Adygei', - en: 'Adyghe; Adygei', android: false, ios: false, }, @@ -159,7 +152,6 @@ export const LANGUAGES: Language[] = [ code3: 'afa', code2: '', name: 'Afro-Asiatic languages', - en: 'Afro-Asiatic languages', android: false, ios: false, }, @@ -167,7 +159,6 @@ export const LANGUAGES: Language[] = [ code3: 'afh', code2: '', name: 'Afrihili', - en: 'Afrihili', android: false, ios: false, }, @@ -175,7 +166,6 @@ export const LANGUAGES: Language[] = [ code3: 'afr', code2: 'af', name: 'Afrikaans', - en: 'Afrikaans', android: false, ios: false, }, @@ -183,7 +173,6 @@ export const LANGUAGES: Language[] = [ code3: 'ain', code2: '', name: 'Ainu', - en: 'Ainu', android: false, ios: false, }, @@ -191,7 +180,6 @@ export const LANGUAGES: Language[] = [ code3: 'aka', code2: 'ak', name: 'Akan', - en: 'Akan', android: false, ios: false, }, @@ -199,7 +187,6 @@ export const LANGUAGES: Language[] = [ code3: 'akk', code2: '', name: 'Akkadian', - en: 'Akkadian', android: false, ios: false, }, @@ -207,7 +194,6 @@ export const LANGUAGES: Language[] = [ code3: 'alb', code2: 'sq', name: 'Albanian', - en: 'Albanian', android: true, ios: false, }, @@ -215,7 +201,6 @@ export const LANGUAGES: Language[] = [ code3: 'ale', code2: '', name: 'Aleut', - en: 'Aleut', android: false, ios: false, }, @@ -223,7 +208,6 @@ export const LANGUAGES: Language[] = [ code3: 'alg', code2: '', name: 'Algonquian languages', - en: 'Algonquian languages', android: false, ios: false, }, @@ -231,15 +215,13 @@ export const LANGUAGES: Language[] = [ code3: 'alt', code2: '', name: 'Southern Altai', - en: 'Southern Altai', android: false, ios: false, }, { code3: 'amh', code2: 'am', - name: 'አማርኛ', - en: 'Amharic', + name: 'Amharic', android: false, ios: false, }, @@ -247,7 +229,6 @@ export const LANGUAGES: Language[] = [ code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)', - en: 'English, Old (ca.450-1100)', android: false, ios: false, }, @@ -255,7 +236,6 @@ export const LANGUAGES: Language[] = [ code3: 'anp', code2: '', name: 'Angika', - en: 'Angika', android: false, ios: false, }, @@ -263,15 +243,13 @@ export const LANGUAGES: Language[] = [ code3: 'apa', code2: '', name: 'Apache languages', - en: 'Apache languages', android: false, ios: false, }, { code3: 'ara', code2: 'ar', - name: 'العربية', - en: 'Arabic', + name: 'Arabic', android: true, ios: false, }, @@ -279,7 +257,6 @@ export const LANGUAGES: Language[] = [ code3: 'arc', code2: '', name: 'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', - en: 'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', android: false, ios: false, }, @@ -287,7 +264,6 @@ export const LANGUAGES: Language[] = [ code3: 'arg', code2: 'an', name: 'Aragonese', - en: 'Aragonese', android: false, ios: false, }, @@ -295,7 +271,6 @@ export const LANGUAGES: Language[] = [ code3: 'arm', code2: 'hy', name: 'Armenian', - en: 'Armenian', android: false, ios: false, }, @@ -303,7 +278,6 @@ export const LANGUAGES: Language[] = [ code3: 'arn', code2: '', name: 'Mapudungun; Mapuche', - en: 'Mapudungun; Mapuche', android: false, ios: false, }, @@ -311,7 +285,6 @@ export const LANGUAGES: Language[] = [ code3: 'arp', code2: '', name: 'Arapaho', - en: 'Arapaho', android: false, ios: false, }, @@ -319,7 +292,6 @@ export const LANGUAGES: Language[] = [ code3: 'art', code2: '', name: 'Artificial languages', - en: 'Artificial languages', android: false, ios: false, }, @@ -327,7 +299,6 @@ export const LANGUAGES: Language[] = [ code3: 'arw', code2: '', name: 'Arawak', - en: 'Arawak', android: false, ios: false, }, @@ -335,7 +306,6 @@ export const LANGUAGES: Language[] = [ code3: 'asm', code2: 'as', name: 'Assamese', - en: 'Assamese', android: false, ios: false, }, @@ -343,7 +313,6 @@ export const LANGUAGES: Language[] = [ code3: 'ast', code2: '', name: 'Asturian', - en: 'Asturian', android: false, ios: false, }, @@ -351,7 +320,6 @@ export const LANGUAGES: Language[] = [ code3: 'ath', code2: '', name: 'Athapascan languages', - en: 'Athapascan languages', android: false, ios: false, }, @@ -359,7 +327,6 @@ export const LANGUAGES: Language[] = [ code3: 'aus', code2: '', name: 'Australian languages', - en: 'Australian languages', android: false, ios: false, }, @@ -367,7 +334,6 @@ export const LANGUAGES: Language[] = [ code3: 'ava', code2: 'av', name: 'Avaric', - en: 'Avaric', android: false, ios: false, }, @@ -375,7 +341,6 @@ export const LANGUAGES: Language[] = [ code3: 'ave', code2: 'ae', name: 'Avestan', - en: 'Avestan', android: false, ios: false, }, @@ -383,7 +348,6 @@ export const LANGUAGES: Language[] = [ code3: 'awa', code2: '', name: 'Awadhi', - en: 'Awadhi', android: false, ios: false, }, @@ -391,15 +355,13 @@ export const LANGUAGES: Language[] = [ code3: 'aym', code2: 'ay', name: 'Aymara', - en: 'Aymara', android: false, ios: false, }, { code3: 'aze', code2: 'az', - name: 'azərbaycan', - en: 'Azerbaijani', + name: 'Azerbaijani', android: false, ios: false, }, @@ -407,7 +369,6 @@ export const LANGUAGES: Language[] = [ code3: 'bad', code2: '', name: 'Banda languages', - en: 'Banda languages', android: false, ios: false, }, @@ -415,7 +376,6 @@ export const LANGUAGES: Language[] = [ code3: 'bai', code2: '', name: 'Bamileke languages', - en: 'Bamileke languages', android: false, ios: false, }, @@ -423,7 +383,6 @@ export const LANGUAGES: Language[] = [ code3: 'bak', code2: 'ba', name: 'Bashkir', - en: 'Bashkir', android: false, ios: false, }, @@ -431,7 +390,6 @@ export const LANGUAGES: Language[] = [ code3: 'bal', code2: '', name: 'Baluchi', - en: 'Baluchi', android: false, ios: false, }, @@ -439,7 +397,6 @@ export const LANGUAGES: Language[] = [ code3: 'bam', code2: 'bm', name: 'Bambara', - en: 'Bambara', android: false, ios: false, }, @@ -447,7 +404,6 @@ export const LANGUAGES: Language[] = [ code3: 'ban', code2: '', name: 'Balinese', - en: 'Balinese', android: false, ios: false, }, @@ -455,7 +411,6 @@ export const LANGUAGES: Language[] = [ code3: 'baq', code2: 'eu', name: 'Basque', - en: 'Basque', android: false, ios: false, }, @@ -463,7 +418,6 @@ export const LANGUAGES: Language[] = [ code3: 'bas', code2: '', name: 'Basa', - en: 'Basa', android: false, ios: false, }, @@ -471,7 +425,6 @@ export const LANGUAGES: Language[] = [ code3: 'bat', code2: '', name: 'Baltic languages', - en: 'Baltic languages', android: false, ios: false, }, @@ -479,7 +432,6 @@ export const LANGUAGES: Language[] = [ code3: 'bej', code2: '', name: 'Beja; Bedawiyet', - en: 'Beja; Bedawiyet', android: false, ios: false, }, @@ -487,7 +439,6 @@ export const LANGUAGES: Language[] = [ code3: 'bel', code2: 'be', name: 'Belarusian', - en: 'Belarusian', android: true, ios: false, }, @@ -495,15 +446,13 @@ export const LANGUAGES: Language[] = [ code3: 'bem', code2: '', name: 'Bemba', - en: 'Bemba', android: false, ios: false, }, { code3: 'ben', code2: 'bn', - name: 'বাংলা', - en: 'Bangla', + name: 'Bangla', android: true, ios: false, }, @@ -511,7 +460,6 @@ export const LANGUAGES: Language[] = [ code3: 'ber', code2: '', name: 'Berber languages', - en: 'Berber languages', android: false, ios: false, }, @@ -519,7 +467,6 @@ export const LANGUAGES: Language[] = [ code3: 'bho', code2: '', name: 'Bhojpuri', - en: 'Bhojpuri', android: false, ios: false, }, @@ -527,7 +474,6 @@ export const LANGUAGES: Language[] = [ code3: 'bih', code2: 'bh', name: 'Bhojpuri', - en: 'Bhojpuri', android: false, ios: false, }, @@ -535,7 +481,6 @@ export const LANGUAGES: Language[] = [ code3: 'bik', code2: '', name: 'Bikol', - en: 'Bikol', android: false, ios: false, }, @@ -543,7 +488,6 @@ export const LANGUAGES: Language[] = [ code3: 'bin', code2: '', name: 'Bini; Edo', - en: 'Bini; Edo', android: false, ios: false, }, @@ -551,7 +495,6 @@ export const LANGUAGES: Language[] = [ code3: 'bis', code2: 'bi', name: 'Bislama', - en: 'Bislama', android: false, ios: false, }, @@ -559,7 +502,6 @@ export const LANGUAGES: Language[] = [ code3: 'bla', code2: '', name: 'Siksika', - en: 'Siksika', android: false, ios: false, }, @@ -567,7 +509,6 @@ export const LANGUAGES: Language[] = [ code3: 'bnt', code2: '', name: 'Bantu languages', - en: 'Bantu languages', android: false, ios: false, }, @@ -575,15 +516,13 @@ export const LANGUAGES: Language[] = [ code3: 'bod', code2: 'bo', name: 'Tibetan', - en: 'Tibetan', android: false, ios: false, }, { code3: 'bos', code2: 'bs', - name: 'bosanski', - en: 'Bosnian', + name: 'Bosnian', android: false, ios: false, }, @@ -591,7 +530,6 @@ export const LANGUAGES: Language[] = [ code3: 'bra', code2: '', name: 'Braj', - en: 'Braj', android: false, ios: false, }, @@ -599,7 +537,6 @@ export const LANGUAGES: Language[] = [ code3: 'bre', code2: 'br', name: 'Breton', - en: 'Breton', android: false, ios: false, }, @@ -607,7 +544,6 @@ export const LANGUAGES: Language[] = [ code3: 'btk', code2: '', name: 'Batak languages', - en: 'Batak languages', android: false, ios: false, }, @@ -615,7 +551,6 @@ export const LANGUAGES: Language[] = [ code3: 'bua', code2: '', name: 'Buriat', - en: 'Buriat', android: false, ios: false, }, @@ -623,15 +558,13 @@ export const LANGUAGES: Language[] = [ code3: 'bug', code2: '', name: 'Buginese', - en: 'Buginese', android: false, ios: false, }, { code3: 'bul', code2: 'bg', - name: 'български', - en: 'Bulgarian', + name: 'Bulgarian', android: true, ios: false, }, @@ -639,7 +572,6 @@ export const LANGUAGES: Language[] = [ code3: 'bur', code2: 'my', name: 'Burmese', - en: 'Burmese', android: false, ios: false, }, @@ -647,7 +579,6 @@ export const LANGUAGES: Language[] = [ code3: 'byn', code2: '', name: 'Blin; Bilin', - en: 'Blin; Bilin', android: false, ios: false, }, @@ -655,7 +586,6 @@ export const LANGUAGES: Language[] = [ code3: 'cad', code2: '', name: 'Caddo', - en: 'Caddo', android: false, ios: false, }, @@ -663,7 +593,6 @@ export const LANGUAGES: Language[] = [ code3: 'cai', code2: '', name: 'Central American Indian languages', - en: 'Central American Indian languages', android: false, ios: false, }, @@ -671,15 +600,13 @@ export const LANGUAGES: Language[] = [ code3: 'car', code2: '', name: 'Galibi Carib', - en: 'Galibi Carib', android: false, ios: false, }, { code3: 'cat', code2: 'ca', - name: 'català', - en: 'Catalan', + name: 'Catalan', android: true, ios: false, }, @@ -687,7 +614,6 @@ export const LANGUAGES: Language[] = [ code3: 'cau', code2: '', name: 'Caucasian languages', - en: 'Caucasian languages', android: false, ios: false, }, @@ -695,7 +621,6 @@ export const LANGUAGES: Language[] = [ code3: 'ceb', code2: '', name: 'Cebuano', - en: 'Cebuano', android: false, ios: false, }, @@ -703,15 +628,13 @@ export const LANGUAGES: Language[] = [ code3: 'cel', code2: '', name: 'Celtic languages', - en: 'Celtic languages', android: false, ios: false, }, { code3: 'ces', code2: 'cs', - name: 'čeština', - en: 'Czech', + name: 'Czech', android: true, ios: false, }, @@ -719,7 +642,6 @@ export const LANGUAGES: Language[] = [ code3: 'cha', code2: 'ch', name: 'Chamorro', - en: 'Chamorro', android: false, ios: false, }, @@ -727,7 +649,6 @@ export const LANGUAGES: Language[] = [ code3: 'chb', code2: '', name: 'Chibcha', - en: 'Chibcha', android: false, ios: false, }, @@ -735,7 +656,6 @@ export const LANGUAGES: Language[] = [ code3: 'che', code2: 'ce', name: 'Chechen', - en: 'Chechen', android: false, ios: false, }, @@ -743,15 +663,13 @@ export const LANGUAGES: Language[] = [ code3: 'chg', code2: '', name: 'Chagatai', - en: 'Chagatai', android: false, ios: false, }, { code3: 'chi', code2: 'zh', - name: '中文', - en: 'Chinese', + name: 'Chinese', android: true, ios: false, }, @@ -759,7 +677,6 @@ export const LANGUAGES: Language[] = [ code3: 'chk', code2: '', name: 'Chuukese', - en: 'Chuukese', android: false, ios: false, }, @@ -767,7 +684,6 @@ export const LANGUAGES: Language[] = [ code3: 'chm', code2: '', name: 'Mari', - en: 'Mari', android: false, ios: false, }, @@ -775,7 +691,6 @@ export const LANGUAGES: Language[] = [ code3: 'chn', code2: '', name: 'Chinook jargon', - en: 'Chinook jargon', android: false, ios: false, }, @@ -783,7 +698,6 @@ export const LANGUAGES: Language[] = [ code3: 'cho', code2: '', name: 'Choctaw', - en: 'Choctaw', android: false, ios: false, }, @@ -791,7 +705,6 @@ export const LANGUAGES: Language[] = [ code3: 'chp', code2: '', name: 'Chipewyan; Dene Suline', - en: 'Chipewyan; Dene Suline', android: false, ios: false, }, @@ -799,7 +712,6 @@ export const LANGUAGES: Language[] = [ code3: 'chr', code2: '', name: 'Cherokee', - en: 'Cherokee', android: false, ios: false, }, @@ -807,7 +719,6 @@ export const LANGUAGES: Language[] = [ code3: 'chu', code2: 'cu', name: 'Church Slavic', - en: 'Church Slavic', android: false, ios: false, }, @@ -815,7 +726,6 @@ export const LANGUAGES: Language[] = [ code3: 'chv', code2: 'cv', name: 'Chuvash', - en: 'Chuvash', android: false, ios: false, }, @@ -823,7 +733,6 @@ export const LANGUAGES: Language[] = [ code3: 'chy', code2: '', name: 'Cheyenne', - en: 'Cheyenne', android: false, ios: false, }, @@ -831,15 +740,13 @@ export const LANGUAGES: Language[] = [ code3: 'cmc', code2: '', name: 'Chamic languages', - en: 'Chamic languages', android: false, ios: false, }, { code3: 'cnr', code2: '', - name: 'srpski (Crna Gora)', - en: 'Serbian (Montenegro)', + name: 'Serbian (Montenegro)', android: false, ios: false, }, @@ -847,7 +754,6 @@ export const LANGUAGES: Language[] = [ code3: 'cop', code2: '', name: 'Coptic', - en: 'Coptic', android: false, ios: false, }, @@ -855,7 +761,6 @@ export const LANGUAGES: Language[] = [ code3: 'cor', code2: 'kw', name: 'Cornish', - en: 'Cornish', android: false, ios: false, }, @@ -863,7 +768,6 @@ export const LANGUAGES: Language[] = [ code3: 'cos', code2: 'co', name: 'Corsican', - en: 'Corsican', android: false, ios: false, }, @@ -871,7 +775,6 @@ export const LANGUAGES: Language[] = [ code3: 'cpe', code2: '', name: 'Creoles and pidgins, English based', - en: 'Creoles and pidgins, English based', android: false, ios: false, }, @@ -879,7 +782,6 @@ export const LANGUAGES: Language[] = [ code3: 'cpf', code2: '', name: 'Creoles and pidgins, French-based', - en: 'Creoles and pidgins, French-based', android: false, ios: false, }, @@ -887,7 +789,6 @@ export const LANGUAGES: Language[] = [ code3: 'cpp', code2: '', name: 'Creoles and pidgins, Portuguese-based', - en: 'Creoles and pidgins, Portuguese-based', android: false, ios: false, }, @@ -895,7 +796,6 @@ export const LANGUAGES: Language[] = [ code3: 'cre', code2: 'cr', name: 'Cree', - en: 'Cree', android: false, ios: false, }, @@ -903,7 +803,6 @@ export const LANGUAGES: Language[] = [ code3: 'crh', code2: '', name: 'Crimean Tatar; Crimean Turkish', - en: 'Crimean Tatar; Crimean Turkish', android: false, ios: false, }, @@ -911,7 +810,6 @@ export const LANGUAGES: Language[] = [ code3: 'crp', code2: '', name: 'Creoles and pidgins', - en: 'Creoles and pidgins', android: false, ios: false, }, @@ -919,7 +817,6 @@ export const LANGUAGES: Language[] = [ code3: 'csb', code2: '', name: 'Kashubian', - en: 'Kashubian', android: false, ios: false, }, @@ -927,7 +824,6 @@ export const LANGUAGES: Language[] = [ code3: 'cus', code2: '', name: 'Cushitic languages', - en: 'Cushitic languages', android: false, ios: false, }, @@ -935,15 +831,13 @@ export const LANGUAGES: Language[] = [ code3: 'cym', code2: 'cy', name: 'Welsh', - en: 'Welsh', android: true, ios: false, }, { code3: 'cze', code2: 'cs', - name: 'čeština', - en: 'Czech', + name: 'Czech', android: true, ios: false, }, @@ -951,15 +845,13 @@ export const LANGUAGES: Language[] = [ code3: 'dak', code2: '', name: 'Dakota', - en: 'Dakota', android: false, ios: false, }, { code3: 'dan', code2: 'da', - name: 'dansk', - en: 'Danish', + name: 'Danish', android: true, ios: false, }, @@ -967,7 +859,6 @@ export const LANGUAGES: Language[] = [ code3: 'dar', code2: '', name: 'Dargwa', - en: 'Dargwa', android: false, ios: false, }, @@ -975,7 +866,6 @@ export const LANGUAGES: Language[] = [ code3: 'day', code2: '', name: 'Land Dayak languages', - en: 'Land Dayak languages', android: false, ios: false, }, @@ -983,7 +873,6 @@ export const LANGUAGES: Language[] = [ code3: 'del', code2: '', name: 'Delaware', - en: 'Delaware', android: false, ios: false, }, @@ -991,15 +880,13 @@ export const LANGUAGES: Language[] = [ code3: 'den', code2: '', name: 'Slave (Athapascan)', - en: 'Slave (Athapascan)', android: false, ios: false, }, { code3: 'deu', code2: 'de', - name: 'Deutsch', - en: 'German', + name: 'German', android: true, ios: true, }, @@ -1007,7 +894,6 @@ export const LANGUAGES: Language[] = [ code3: 'dgr', code2: '', name: 'Dogrib', - en: 'Dogrib', android: false, ios: false, }, @@ -1015,7 +901,6 @@ export const LANGUAGES: Language[] = [ code3: 'din', code2: '', name: 'Dinka', - en: 'Dinka', android: false, ios: false, }, @@ -1023,7 +908,6 @@ export const LANGUAGES: Language[] = [ code3: 'div', code2: 'dv', name: 'Divehi', - en: 'Divehi', android: false, ios: false, }, @@ -1031,7 +915,6 @@ export const LANGUAGES: Language[] = [ code3: 'doi', code2: '', name: 'Dogri', - en: 'Dogri', android: false, ios: false, }, @@ -1039,7 +922,6 @@ export const LANGUAGES: Language[] = [ code3: 'dra', code2: '', name: 'Dravidian languages', - en: 'Dravidian languages', android: false, ios: false, }, @@ -1047,7 +929,6 @@ export const LANGUAGES: Language[] = [ code3: 'dsb', code2: '', name: 'Lower Sorbian', - en: 'Lower Sorbian', android: false, ios: false, }, @@ -1055,7 +936,6 @@ export const LANGUAGES: Language[] = [ code3: 'dua', code2: '', name: 'Duala', - en: 'Duala', android: false, ios: false, }, @@ -1063,15 +943,13 @@ export const LANGUAGES: Language[] = [ code3: 'dum', code2: '', name: 'Dutch, Middle (ca.1050-1350)', - en: 'Dutch, Middle (ca.1050-1350)', android: false, ios: false, }, { code3: 'dut', code2: 'nl', - name: 'Nederlands', - en: 'Dutch', + name: 'Dutch', android: true, ios: true, }, @@ -1079,7 +957,6 @@ export const LANGUAGES: Language[] = [ code3: 'dyu', code2: '', name: 'Dyula', - en: 'Dyula', android: false, ios: false, }, @@ -1087,7 +964,6 @@ export const LANGUAGES: Language[] = [ code3: 'dzo', code2: 'dz', name: 'Dzongkha', - en: 'Dzongkha', android: false, ios: false, }, @@ -1095,7 +971,6 @@ export const LANGUAGES: Language[] = [ code3: 'efi', code2: '', name: 'Efik', - en: 'Efik', android: false, ios: false, }, @@ -1103,7 +978,6 @@ export const LANGUAGES: Language[] = [ code3: 'egy', code2: '', name: 'Egyptian (Ancient)', - en: 'Egyptian (Ancient)', android: false, ios: false, }, @@ -1111,15 +985,13 @@ export const LANGUAGES: Language[] = [ code3: 'eka', code2: '', name: 'Ekajuk', - en: 'Ekajuk', android: false, ios: false, }, { code3: 'ell', code2: 'el', - name: 'Ελληνικά', - en: 'Greek', + name: 'Greek', android: true, ios: false, }, @@ -1127,7 +999,6 @@ export const LANGUAGES: Language[] = [ code3: 'elx', code2: '', name: 'Elamite', - en: 'Elamite', android: false, ios: false, }, @@ -1135,7 +1006,6 @@ export const LANGUAGES: Language[] = [ code3: 'eng', code2: 'en', name: 'English', - en: 'English', android: true, ios: true, }, @@ -1143,7 +1013,6 @@ export const LANGUAGES: Language[] = [ code3: 'enm', code2: '', name: 'English, Middle (1100-1500)', - en: 'English, Middle (1100-1500)', android: false, ios: false, }, @@ -1151,15 +1020,13 @@ export const LANGUAGES: Language[] = [ code3: 'epo', code2: 'eo', name: 'Esperanto', - en: 'Esperanto', android: true, ios: false, }, { code3: 'est', code2: 'et', - name: 'eesti', - en: 'Estonian', + name: 'Estonian', android: true, ios: false, }, @@ -1167,7 +1034,6 @@ export const LANGUAGES: Language[] = [ code3: 'eus', code2: 'eu', name: 'Basque', - en: 'Basque', android: false, ios: false, }, @@ -1175,7 +1041,6 @@ export const LANGUAGES: Language[] = [ code3: 'ewe', code2: 'ee', name: 'Ewe', - en: 'Ewe', android: false, ios: false, }, @@ -1183,7 +1048,6 @@ export const LANGUAGES: Language[] = [ code3: 'ewo', code2: '', name: 'Ewondo', - en: 'Ewondo', android: false, ios: false, }, @@ -1191,7 +1055,6 @@ export const LANGUAGES: Language[] = [ code3: 'fan', code2: '', name: 'Fang', - en: 'Fang', android: false, ios: false, }, @@ -1199,15 +1062,13 @@ export const LANGUAGES: Language[] = [ code3: 'fao', code2: 'fo', name: 'Faroese', - en: 'Faroese', android: false, ios: false, }, { code3: 'fas', code2: 'fa', - name: 'فارسی', - en: 'Persian', + name: 'Persian', android: true, ios: false, }, @@ -1215,7 +1076,6 @@ export const LANGUAGES: Language[] = [ code3: 'fat', code2: '', name: 'Akan', - en: 'Akan', android: false, ios: false, }, @@ -1223,7 +1083,6 @@ export const LANGUAGES: Language[] = [ code3: 'fij', code2: 'fj', name: 'Fijian', - en: 'Fijian', android: false, ios: false, }, @@ -1231,15 +1090,13 @@ export const LANGUAGES: Language[] = [ code3: 'fil', code2: '', name: 'Filipino', - en: 'Filipino', android: false, ios: false, }, { code3: 'fin', code2: 'fi', - name: 'suomi', - en: 'Finnish', + name: 'Finnish', android: true, ios: false, }, @@ -1247,7 +1104,6 @@ export const LANGUAGES: Language[] = [ code3: 'fiu', code2: '', name: 'Finno-Ugrian languages', - en: 'Finno-Ugrian languages', android: false, ios: false, }, @@ -1255,23 +1111,20 @@ export const LANGUAGES: Language[] = [ code3: 'fon', code2: '', name: 'Fon', - en: 'Fon', android: false, ios: false, }, { code3: 'fra', code2: 'fr', - name: 'français', - en: 'French', + name: 'French', android: true, ios: true, }, { code3: 'fre', code2: 'fr', - name: 'français', - en: 'French', + name: 'French', android: true, ios: true, }, @@ -1279,7 +1132,6 @@ export const LANGUAGES: Language[] = [ code3: 'frm', code2: '', name: 'French, Middle (ca.1400-1600)', - en: 'French, Middle (ca.1400-1600)', android: false, ios: false, }, @@ -1287,7 +1139,6 @@ export const LANGUAGES: Language[] = [ code3: 'fro', code2: '', name: 'French, Old (842-ca.1400)', - en: 'French, Old (842-ca.1400)', android: false, ios: false, }, @@ -1295,7 +1146,6 @@ export const LANGUAGES: Language[] = [ code3: 'frr', code2: '', name: 'Northern Frisian', - en: 'Northern Frisian', android: false, ios: false, }, @@ -1303,7 +1153,6 @@ export const LANGUAGES: Language[] = [ code3: 'frs', code2: '', name: 'Eastern Frisian', - en: 'Eastern Frisian', android: false, ios: false, }, @@ -1311,7 +1160,6 @@ export const LANGUAGES: Language[] = [ code3: 'fry', code2: 'fy', name: 'Western Frisian', - en: 'Western Frisian', android: false, ios: false, }, @@ -1319,7 +1167,6 @@ export const LANGUAGES: Language[] = [ code3: 'ful', code2: 'ff', name: 'Fulah', - en: 'Fulah', android: false, ios: false, }, @@ -1327,7 +1174,6 @@ export const LANGUAGES: Language[] = [ code3: 'fur', code2: '', name: 'Friulian', - en: 'Friulian', android: false, ios: false, }, @@ -1335,7 +1181,6 @@ export const LANGUAGES: Language[] = [ code3: 'gaa', code2: '', name: 'Ga', - en: 'Ga', android: false, ios: false, }, @@ -1343,7 +1188,6 @@ export const LANGUAGES: Language[] = [ code3: 'gay', code2: '', name: 'Gayo', - en: 'Gayo', android: false, ios: false, }, @@ -1351,7 +1195,6 @@ export const LANGUAGES: Language[] = [ code3: 'gba', code2: '', name: 'Gbaya', - en: 'Gbaya', android: false, ios: false, }, @@ -1359,7 +1202,6 @@ export const LANGUAGES: Language[] = [ code3: 'gem', code2: '', name: 'Germanic languages', - en: 'Germanic languages', android: false, ios: false, }, @@ -1367,15 +1209,13 @@ export const LANGUAGES: Language[] = [ code3: 'geo', code2: 'ka', name: 'Georgian', - en: 'Georgian', android: true, ios: false, }, { code3: 'ger', code2: 'de', - name: 'Deutsch', - en: 'German', + name: 'German', android: true, ios: true, }, @@ -1383,7 +1223,6 @@ export const LANGUAGES: Language[] = [ code3: 'gez', code2: '', name: 'Geez', - en: 'Geez', android: false, ios: false, }, @@ -1391,7 +1230,6 @@ export const LANGUAGES: Language[] = [ code3: 'gil', code2: '', name: 'Gilbertese', - en: 'Gilbertese', android: false, ios: false, }, @@ -1399,7 +1237,6 @@ export const LANGUAGES: Language[] = [ code3: 'gla', code2: 'gd', name: 'Scottish Gaelic', - en: 'Scottish Gaelic', android: false, ios: false, }, @@ -1407,7 +1244,6 @@ export const LANGUAGES: Language[] = [ code3: 'gle', code2: 'ga', name: 'Irish', - en: 'Irish', android: true, ios: false, }, @@ -1415,7 +1251,6 @@ export const LANGUAGES: Language[] = [ code3: 'glg', code2: 'gl', name: 'Galician', - en: 'Galician', android: true, ios: false, }, @@ -1423,7 +1258,6 @@ export const LANGUAGES: Language[] = [ code3: 'glv', code2: 'gv', name: 'Manx', - en: 'Manx', android: false, ios: false, }, @@ -1431,7 +1265,6 @@ export const LANGUAGES: Language[] = [ code3: 'gmh', code2: '', name: 'German, Middle High (ca.1050-1500)', - en: 'German, Middle High (ca.1050-1500)', android: false, ios: false, }, @@ -1439,7 +1272,6 @@ export const LANGUAGES: Language[] = [ code3: 'goh', code2: '', name: 'German, Old High (ca.750-1050)', - en: 'German, Old High (ca.750-1050)', android: false, ios: false, }, @@ -1447,7 +1279,6 @@ export const LANGUAGES: Language[] = [ code3: 'gon', code2: '', name: 'Gondi', - en: 'Gondi', android: false, ios: false, }, @@ -1455,7 +1286,6 @@ export const LANGUAGES: Language[] = [ code3: 'gor', code2: '', name: 'Gorontalo', - en: 'Gorontalo', android: false, ios: false, }, @@ -1463,7 +1293,6 @@ export const LANGUAGES: Language[] = [ code3: 'got', code2: '', name: 'Gothic', - en: 'Gothic', android: false, ios: false, }, @@ -1471,7 +1300,6 @@ export const LANGUAGES: Language[] = [ code3: 'grb', code2: '', name: 'Grebo', - en: 'Grebo', android: false, ios: false, }, @@ -1479,15 +1307,13 @@ export const LANGUAGES: Language[] = [ code3: 'grc', code2: '', name: 'Ancient Greek', - en: 'Ancient Greek', android: false, ios: false, }, { code3: 'gre', code2: 'el', - name: 'Ελληνικά', - en: 'Greek', + name: 'Greek', android: true, ios: false, }, @@ -1495,7 +1321,6 @@ export const LANGUAGES: Language[] = [ code3: 'grn', code2: 'gn', name: 'Guarani', - en: 'Guarani', android: false, ios: false, }, @@ -1503,15 +1328,13 @@ export const LANGUAGES: Language[] = [ code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian', - en: 'Swiss German; Alemannic; Alsatian', android: false, ios: false, }, { code3: 'guj', code2: 'gu', - name: 'ગુજરાતી', - en: 'Gujarati', + name: 'Gujarati', android: true, ios: false, }, @@ -1519,7 +1342,6 @@ export const LANGUAGES: Language[] = [ code3: 'gwi', code2: '', name: "Gwich'in", - en: "Gwich'in", android: false, ios: false, }, @@ -1527,7 +1349,6 @@ export const LANGUAGES: Language[] = [ code3: 'hai', code2: '', name: 'Haida', - en: 'Haida', android: false, ios: false, }, @@ -1535,7 +1356,6 @@ export const LANGUAGES: Language[] = [ code3: 'hat', code2: 'ht', name: 'Haitian Creole', - en: 'Haitian Creole', android: true, ios: false, }, @@ -1543,7 +1363,6 @@ export const LANGUAGES: Language[] = [ code3: 'hau', code2: 'ha', name: 'Hausa', - en: 'Hausa', android: false, ios: false, }, @@ -1551,15 +1370,13 @@ export const LANGUAGES: Language[] = [ code3: 'haw', code2: '', name: 'Hawaiian', - en: 'Hawaiian', android: false, ios: false, }, { code3: 'heb', code2: 'he', - name: 'עברית', - en: 'Hebrew', + name: 'Hebrew', android: true, ios: false, }, @@ -1567,7 +1384,6 @@ export const LANGUAGES: Language[] = [ code3: 'her', code2: 'hz', name: 'Herero', - en: 'Herero', android: false, ios: false, }, @@ -1575,7 +1391,6 @@ export const LANGUAGES: Language[] = [ code3: 'hil', code2: '', name: 'Hiligaynon', - en: 'Hiligaynon', android: false, ios: false, }, @@ -1583,15 +1398,13 @@ export const LANGUAGES: Language[] = [ code3: 'him', code2: '', name: 'Himachali languages; Western Pahari languages', - en: 'Himachali languages; Western Pahari languages', android: false, ios: false, }, { code3: 'hin', code2: 'hi', - name: 'हिन्दी', - en: 'Hindi', + name: 'Hindi', android: true, ios: true, }, @@ -1599,7 +1412,6 @@ export const LANGUAGES: Language[] = [ code3: 'hit', code2: '', name: 'Hittite', - en: 'Hittite', android: false, ios: false, }, @@ -1607,7 +1419,6 @@ export const LANGUAGES: Language[] = [ code3: 'hmn', code2: '', name: 'Hmong', - en: 'Hmong', android: false, ios: false, }, @@ -1615,15 +1426,13 @@ export const LANGUAGES: Language[] = [ code3: 'hmo', code2: 'ho', name: 'Hiri Motu', - en: 'Hiri Motu', android: false, ios: false, }, { code3: 'hrv', code2: 'hr', - name: 'hrvatski', - en: 'Croatian', + name: 'Croatian', android: true, ios: false, }, @@ -1631,15 +1440,13 @@ export const LANGUAGES: Language[] = [ code3: 'hsb', code2: '', name: 'Upper Sorbian', - en: 'Upper Sorbian', android: false, ios: false, }, { code3: 'hun', code2: 'hu', - name: 'magyar', - en: 'Hungarian', + name: 'Hungarian', android: true, ios: false, }, @@ -1647,7 +1454,6 @@ export const LANGUAGES: Language[] = [ code3: 'hup', code2: '', name: 'Hupa', - en: 'Hupa', android: false, ios: false, }, @@ -1655,7 +1461,6 @@ export const LANGUAGES: Language[] = [ code3: 'hye', code2: 'hy', name: 'Armenian', - en: 'Armenian', android: false, ios: false, }, @@ -1663,7 +1468,6 @@ export const LANGUAGES: Language[] = [ code3: 'iba', code2: '', name: 'Iban', - en: 'Iban', android: false, ios: false, }, @@ -1671,7 +1475,6 @@ export const LANGUAGES: Language[] = [ code3: 'ibo', code2: 'ig', name: 'Igbo', - en: 'Igbo', android: false, ios: false, }, @@ -1679,7 +1482,6 @@ export const LANGUAGES: Language[] = [ code3: 'ice', code2: 'is', name: 'Icelandic', - en: 'Icelandic', android: true, ios: false, }, @@ -1687,7 +1489,6 @@ export const LANGUAGES: Language[] = [ code3: 'ido', code2: 'io', name: 'Ido', - en: 'Ido', android: false, ios: false, }, @@ -1695,7 +1496,6 @@ export const LANGUAGES: Language[] = [ code3: 'iii', code2: 'ii', name: 'Sichuan Yi; Nuosu', - en: 'Sichuan Yi; Nuosu', android: false, ios: false, }, @@ -1703,7 +1503,6 @@ export const LANGUAGES: Language[] = [ code3: 'ijo', code2: '', name: 'Ijo languages', - en: 'Ijo languages', android: false, ios: false, }, @@ -1711,7 +1510,6 @@ export const LANGUAGES: Language[] = [ code3: 'iku', code2: 'iu', name: 'Inuktitut', - en: 'Inuktitut', android: false, ios: false, }, @@ -1719,7 +1517,6 @@ export const LANGUAGES: Language[] = [ code3: 'ile', code2: 'ie', name: 'Interlingue', - en: 'Interlingue', android: false, ios: false, }, @@ -1727,7 +1524,6 @@ export const LANGUAGES: Language[] = [ code3: 'ilo', code2: '', name: 'Iloko', - en: 'Iloko', android: false, ios: false, }, @@ -1735,7 +1531,6 @@ export const LANGUAGES: Language[] = [ code3: 'ina', code2: 'ia', name: 'Interlingua', - en: 'Interlingua', android: false, ios: false, }, @@ -1743,15 +1538,13 @@ export const LANGUAGES: Language[] = [ code3: 'inc', code2: '', name: 'Indic languages', - en: 'Indic languages', android: false, ios: false, }, { code3: 'ind', code2: 'id', - name: 'Indonesia', - en: 'Indonesian', + name: 'Indonesian', android: true, ios: false, }, @@ -1759,7 +1552,6 @@ export const LANGUAGES: Language[] = [ code3: 'ine', code2: '', name: 'Indo-European languages', - en: 'Indo-European languages', android: false, ios: false, }, @@ -1767,7 +1559,6 @@ export const LANGUAGES: Language[] = [ code3: 'inh', code2: '', name: 'Ingush', - en: 'Ingush', android: false, ios: false, }, @@ -1775,7 +1566,6 @@ export const LANGUAGES: Language[] = [ code3: 'ipk', code2: 'ik', name: 'Inupiaq', - en: 'Inupiaq', android: false, ios: false, }, @@ -1783,7 +1573,6 @@ export const LANGUAGES: Language[] = [ code3: 'ira', code2: '', name: 'Iranian languages', - en: 'Iranian languages', android: false, ios: false, }, @@ -1791,7 +1580,6 @@ export const LANGUAGES: Language[] = [ code3: 'iro', code2: '', name: 'Iroquoian languages', - en: 'Iroquoian languages', android: false, ios: false, }, @@ -1799,15 +1587,13 @@ export const LANGUAGES: Language[] = [ code3: 'isl', code2: 'is', name: 'Icelandic', - en: 'Icelandic', android: true, ios: false, }, { code3: 'ita', code2: 'it', - name: 'italiano', - en: 'Italian', + name: 'Italian', android: true, ios: true, }, @@ -1815,7 +1601,6 @@ export const LANGUAGES: Language[] = [ code3: 'jav', code2: 'jv', name: 'Javanese', - en: 'Javanese', android: false, ios: false, }, @@ -1823,15 +1608,13 @@ export const LANGUAGES: Language[] = [ code3: 'jbo', code2: '', name: 'Lojban', - en: 'Lojban', android: false, ios: false, }, { code3: 'jpn', code2: 'ja', - name: '日本語', - en: 'Japanese', + name: 'Japanese', android: true, ios: true, }, @@ -1839,7 +1622,6 @@ export const LANGUAGES: Language[] = [ code3: 'jpr', code2: '', name: 'Judeo-Persian', - en: 'Judeo-Persian', android: false, ios: false, }, @@ -1847,7 +1629,6 @@ export const LANGUAGES: Language[] = [ code3: 'jrb', code2: '', name: 'Judeo-Arabic', - en: 'Judeo-Arabic', android: false, ios: false, }, @@ -1855,7 +1636,6 @@ export const LANGUAGES: Language[] = [ code3: 'kaa', code2: '', name: 'Kara-Kalpak', - en: 'Kara-Kalpak', android: false, ios: false, }, @@ -1863,7 +1643,6 @@ export const LANGUAGES: Language[] = [ code3: 'kab', code2: '', name: 'Kabyle', - en: 'Kabyle', android: false, ios: false, }, @@ -1871,7 +1650,6 @@ export const LANGUAGES: Language[] = [ code3: 'kac', code2: '', name: 'Kachin; Jingpho', - en: 'Kachin; Jingpho', android: false, ios: false, }, @@ -1879,7 +1657,6 @@ export const LANGUAGES: Language[] = [ code3: 'kal', code2: 'kl', name: 'Kalaallisut', - en: 'Kalaallisut', android: false, ios: false, }, @@ -1887,15 +1664,13 @@ export const LANGUAGES: Language[] = [ code3: 'kam', code2: '', name: 'Kamba', - en: 'Kamba', android: false, ios: false, }, { code3: 'kan', code2: 'kn', - name: 'ಕನ್ನಡ', - en: 'Kannada', + name: 'Kannada', android: true, ios: false, }, @@ -1903,7 +1678,6 @@ export const LANGUAGES: Language[] = [ code3: 'kar', code2: '', name: 'Karen languages', - en: 'Karen languages', android: false, ios: false, }, @@ -1911,7 +1685,6 @@ export const LANGUAGES: Language[] = [ code3: 'kas', code2: 'ks', name: 'Kashmiri', - en: 'Kashmiri', android: false, ios: false, }, @@ -1919,7 +1692,6 @@ export const LANGUAGES: Language[] = [ code3: 'kat', code2: 'ka', name: 'Georgian', - en: 'Georgian', android: true, ios: false, }, @@ -1927,7 +1699,6 @@ export const LANGUAGES: Language[] = [ code3: 'kau', code2: 'kr', name: 'Kanuri', - en: 'Kanuri', android: false, ios: false, }, @@ -1935,7 +1706,6 @@ export const LANGUAGES: Language[] = [ code3: 'kaw', code2: '', name: 'Kawi', - en: 'Kawi', android: false, ios: false, }, @@ -1943,7 +1713,6 @@ export const LANGUAGES: Language[] = [ code3: 'kaz', code2: 'kk', name: 'Kazakh', - en: 'Kazakh', android: false, ios: false, }, @@ -1951,7 +1720,6 @@ export const LANGUAGES: Language[] = [ code3: 'kbd', code2: '', name: 'Kabardian', - en: 'Kabardian', android: false, ios: false, }, @@ -1959,7 +1727,6 @@ export const LANGUAGES: Language[] = [ code3: 'kha', code2: '', name: 'Khasi', - en: 'Khasi', android: false, ios: false, }, @@ -1967,7 +1734,6 @@ export const LANGUAGES: Language[] = [ code3: 'khi', code2: '', name: 'Khoisan languages', - en: 'Khoisan languages', android: false, ios: false, }, @@ -1975,7 +1741,6 @@ export const LANGUAGES: Language[] = [ code3: 'khm', code2: 'km', name: 'Khmer', - en: 'Khmer', android: false, ios: false, }, @@ -1983,7 +1748,6 @@ export const LANGUAGES: Language[] = [ code3: 'kho', code2: '', name: 'Khotanese; Sakan', - en: 'Khotanese; Sakan', android: false, ios: false, }, @@ -1991,7 +1755,6 @@ export const LANGUAGES: Language[] = [ code3: 'kik', code2: 'ki', name: 'Kikuyu; Gikuyu', - en: 'Kikuyu; Gikuyu', android: false, ios: false, }, @@ -1999,7 +1762,6 @@ export const LANGUAGES: Language[] = [ code3: 'kin', code2: 'rw', name: 'Kinyarwanda', - en: 'Kinyarwanda', android: false, ios: false, }, @@ -2007,7 +1769,6 @@ export const LANGUAGES: Language[] = [ code3: 'kir', code2: 'ky', name: 'Kyrgyz', - en: 'Kyrgyz', android: false, ios: false, }, @@ -2015,15 +1776,13 @@ export const LANGUAGES: Language[] = [ code3: 'kmb', code2: '', name: 'Kimbundu', - en: 'Kimbundu', android: false, ios: false, }, { code3: 'kok', code2: '', - name: 'कोंकणी', - en: 'Konkani', + name: 'Konkani', android: false, ios: false, }, @@ -2031,7 +1790,6 @@ export const LANGUAGES: Language[] = [ code3: 'kom', code2: 'kv', name: 'Komi', - en: 'Komi', android: false, ios: false, }, @@ -2039,15 +1797,13 @@ export const LANGUAGES: Language[] = [ code3: 'kon', code2: 'kg', name: 'Kongo', - en: 'Kongo', android: false, ios: false, }, { code3: 'kor', code2: 'ko', - name: '한국어', - en: 'Korean', + name: 'Korean', android: true, ios: true, }, @@ -2055,7 +1811,6 @@ export const LANGUAGES: Language[] = [ code3: 'kos', code2: '', name: 'Kosraean', - en: 'Kosraean', android: false, ios: false, }, @@ -2063,7 +1818,6 @@ export const LANGUAGES: Language[] = [ code3: 'kpe', code2: '', name: 'Kpelle', - en: 'Kpelle', android: false, ios: false, }, @@ -2071,7 +1825,6 @@ export const LANGUAGES: Language[] = [ code3: 'krc', code2: '', name: 'Karachay-Balkar', - en: 'Karachay-Balkar', android: false, ios: false, }, @@ -2079,7 +1832,6 @@ export const LANGUAGES: Language[] = [ code3: 'krl', code2: '', name: 'Karelian', - en: 'Karelian', android: false, ios: false, }, @@ -2087,7 +1839,6 @@ export const LANGUAGES: Language[] = [ code3: 'kro', code2: '', name: 'Kru languages', - en: 'Kru languages', android: false, ios: false, }, @@ -2095,7 +1846,6 @@ export const LANGUAGES: Language[] = [ code3: 'kru', code2: '', name: 'Kurukh', - en: 'Kurukh', android: false, ios: false, }, @@ -2103,7 +1853,6 @@ export const LANGUAGES: Language[] = [ code3: 'kua', code2: 'kj', name: 'Kuanyama; Kwanyama', - en: 'Kuanyama; Kwanyama', android: false, ios: false, }, @@ -2111,7 +1860,6 @@ export const LANGUAGES: Language[] = [ code3: 'kum', code2: '', name: 'Kumyk', - en: 'Kumyk', android: false, ios: false, }, @@ -2119,7 +1867,6 @@ export const LANGUAGES: Language[] = [ code3: 'kur', code2: 'ku', name: 'Kurdish', - en: 'Kurdish', android: false, ios: false, }, @@ -2127,7 +1874,6 @@ export const LANGUAGES: Language[] = [ code3: 'kut', code2: '', name: 'Kutenai', - en: 'Kutenai', android: false, ios: false, }, @@ -2135,7 +1881,6 @@ export const LANGUAGES: Language[] = [ code3: 'lad', code2: '', name: 'Ladino', - en: 'Ladino', android: false, ios: false, }, @@ -2143,7 +1888,6 @@ export const LANGUAGES: Language[] = [ code3: 'lah', code2: '', name: 'Lahnda', - en: 'Lahnda', android: false, ios: false, }, @@ -2151,7 +1895,6 @@ export const LANGUAGES: Language[] = [ code3: 'lam', code2: '', name: 'Lamba', - en: 'Lamba', android: false, ios: false, }, @@ -2159,7 +1902,6 @@ export const LANGUAGES: Language[] = [ code3: 'lao', code2: 'lo', name: 'Lao', - en: 'Lao', android: false, ios: false, }, @@ -2167,15 +1909,13 @@ export const LANGUAGES: Language[] = [ code3: 'lat', code2: 'la', name: 'Latin', - en: 'Latin', android: false, ios: false, }, { code3: 'lav', code2: 'lv', - name: 'latviešu', - en: 'Latvian', + name: 'Latvian', android: true, ios: false, }, @@ -2183,7 +1923,6 @@ export const LANGUAGES: Language[] = [ code3: 'lez', code2: '', name: 'Lezghian', - en: 'Lezghian', android: false, ios: false, }, @@ -2191,7 +1930,6 @@ export const LANGUAGES: Language[] = [ code3: 'lim', code2: 'li', name: 'Limburgish', - en: 'Limburgish', android: false, ios: false, }, @@ -2199,15 +1937,13 @@ export const LANGUAGES: Language[] = [ code3: 'lin', code2: 'ln', name: 'Lingala', - en: 'Lingala', android: false, ios: false, }, { code3: 'lit', code2: 'lt', - name: 'lietuvių', - en: 'Lithuanian', + name: 'Lithuanian', android: true, ios: false, }, @@ -2215,7 +1951,6 @@ export const LANGUAGES: Language[] = [ code3: 'lol', code2: '', name: 'Mongo', - en: 'Mongo', android: false, ios: false, }, @@ -2223,7 +1958,6 @@ export const LANGUAGES: Language[] = [ code3: 'loz', code2: '', name: 'Lozi', - en: 'Lozi', android: false, ios: false, }, @@ -2231,7 +1965,6 @@ export const LANGUAGES: Language[] = [ code3: 'ltz', code2: 'lb', name: 'Luxembourgish', - en: 'Luxembourgish', android: false, ios: false, }, @@ -2239,7 +1972,6 @@ export const LANGUAGES: Language[] = [ code3: 'lua', code2: '', name: 'Luba-Lulua', - en: 'Luba-Lulua', android: false, ios: false, }, @@ -2247,7 +1979,6 @@ export const LANGUAGES: Language[] = [ code3: 'lub', code2: 'lu', name: 'Luba-Katanga', - en: 'Luba-Katanga', android: false, ios: false, }, @@ -2255,7 +1986,6 @@ export const LANGUAGES: Language[] = [ code3: 'lug', code2: 'lg', name: 'Ganda', - en: 'Ganda', android: false, ios: false, }, @@ -2263,7 +1993,6 @@ export const LANGUAGES: Language[] = [ code3: 'lui', code2: '', name: 'Luiseno', - en: 'Luiseno', android: false, ios: false, }, @@ -2271,7 +2000,6 @@ export const LANGUAGES: Language[] = [ code3: 'lun', code2: '', name: 'Lunda', - en: 'Lunda', android: false, ios: false, }, @@ -2279,7 +2007,6 @@ export const LANGUAGES: Language[] = [ code3: 'luo', code2: '', name: 'Luo (Kenya and Tanzania)', - en: 'Luo (Kenya and Tanzania)', android: false, ios: false, }, @@ -2287,7 +2014,6 @@ export const LANGUAGES: Language[] = [ code3: 'lus', code2: '', name: 'Mizo', - en: 'Mizo', android: false, ios: false, }, @@ -2295,7 +2021,6 @@ export const LANGUAGES: Language[] = [ code3: 'mac', code2: 'mk', name: 'Macedonian', - en: 'Macedonian', android: true, ios: false, }, @@ -2303,7 +2028,6 @@ export const LANGUAGES: Language[] = [ code3: 'mad', code2: '', name: 'Madurese', - en: 'Madurese', android: false, ios: false, }, @@ -2311,7 +2035,6 @@ export const LANGUAGES: Language[] = [ code3: 'mag', code2: '', name: 'Magahi', - en: 'Magahi', android: false, ios: false, }, @@ -2319,7 +2042,6 @@ export const LANGUAGES: Language[] = [ code3: 'mah', code2: 'mh', name: 'Marshallese', - en: 'Marshallese', android: false, ios: false, }, @@ -2327,7 +2049,6 @@ export const LANGUAGES: Language[] = [ code3: 'mai', code2: '', name: 'Maithili', - en: 'Maithili', android: false, ios: false, }, @@ -2335,15 +2056,13 @@ export const LANGUAGES: Language[] = [ code3: 'mak', code2: '', name: 'Makasar', - en: 'Makasar', android: false, ios: false, }, { code3: 'mal', code2: 'ml', - name: 'മലയാളം', - en: 'Malayalam', + name: 'Malayalam', android: false, ios: false, }, @@ -2351,7 +2070,6 @@ export const LANGUAGES: Language[] = [ code3: 'man', code2: '', name: 'Mandingo', - en: 'Mandingo', android: false, ios: false, }, @@ -2359,7 +2077,6 @@ export const LANGUAGES: Language[] = [ code3: 'mao', code2: 'mi', name: 'Māori', - en: 'Māori', android: false, ios: false, }, @@ -2367,15 +2084,13 @@ export const LANGUAGES: Language[] = [ code3: 'map', code2: '', name: 'Austronesian languages', - en: 'Austronesian languages', android: false, ios: false, }, { code3: 'mar', code2: 'mr', - name: 'मराठी', - en: 'Marathi', + name: 'Marathi', android: true, ios: false, }, @@ -2383,15 +2098,13 @@ export const LANGUAGES: Language[] = [ code3: 'mas', code2: '', name: 'Masai', - en: 'Masai', android: false, ios: false, }, { code3: 'may', code2: 'ms', - name: 'Melayu', - en: 'Malay', + name: 'Malay', android: true, ios: false, }, @@ -2399,7 +2112,6 @@ export const LANGUAGES: Language[] = [ code3: 'mdf', code2: '', name: 'Moksha', - en: 'Moksha', android: false, ios: false, }, @@ -2407,7 +2119,6 @@ export const LANGUAGES: Language[] = [ code3: 'mdr', code2: '', name: 'Mandar', - en: 'Mandar', android: false, ios: false, }, @@ -2415,7 +2126,6 @@ export const LANGUAGES: Language[] = [ code3: 'men', code2: '', name: 'Mende', - en: 'Mende', android: false, ios: false, }, @@ -2423,7 +2133,6 @@ export const LANGUAGES: Language[] = [ code3: 'mga', code2: '', name: 'Irish, Middle (900-1200)', - en: 'Irish, Middle (900-1200)', android: false, ios: false, }, @@ -2431,7 +2140,6 @@ export const LANGUAGES: Language[] = [ code3: 'mic', code2: '', name: "Mi'kmaq; Micmac", - en: "Mi'kmaq; Micmac", android: false, ios: false, }, @@ -2439,7 +2147,6 @@ export const LANGUAGES: Language[] = [ code3: 'min', code2: '', name: 'Minangkabau', - en: 'Minangkabau', android: false, ios: false, }, @@ -2447,7 +2154,6 @@ export const LANGUAGES: Language[] = [ code3: 'mis', code2: '', name: 'Uncoded languages', - en: 'Uncoded languages', android: false, ios: false, }, @@ -2455,7 +2161,6 @@ export const LANGUAGES: Language[] = [ code3: 'mkd', code2: 'mk', name: 'Macedonian', - en: 'Macedonian', android: true, ios: false, }, @@ -2463,7 +2168,6 @@ export const LANGUAGES: Language[] = [ code3: 'mkh', code2: '', name: 'Mon-Khmer languages', - en: 'Mon-Khmer languages', android: false, ios: false, }, @@ -2471,7 +2175,6 @@ export const LANGUAGES: Language[] = [ code3: 'mlg', code2: 'mg', name: 'Malagasy', - en: 'Malagasy', android: false, ios: false, }, @@ -2479,7 +2182,6 @@ export const LANGUAGES: Language[] = [ code3: 'mlt', code2: 'mt', name: 'Maltese', - en: 'Maltese', android: true, ios: false, }, @@ -2487,7 +2189,6 @@ export const LANGUAGES: Language[] = [ code3: 'mnc', code2: '', name: 'Manchu', - en: 'Manchu', android: false, ios: false, }, @@ -2495,7 +2196,6 @@ export const LANGUAGES: Language[] = [ code3: 'mni', code2: '', name: 'Manipuri', - en: 'Manipuri', android: false, ios: false, }, @@ -2503,7 +2203,6 @@ export const LANGUAGES: Language[] = [ code3: 'mno', code2: '', name: 'Manobo languages', - en: 'Manobo languages', android: false, ios: false, }, @@ -2511,7 +2210,6 @@ export const LANGUAGES: Language[] = [ code3: 'moh', code2: '', name: 'Mohawk', - en: 'Mohawk', android: false, ios: false, }, @@ -2519,7 +2217,6 @@ export const LANGUAGES: Language[] = [ code3: 'mon', code2: 'mn', name: 'Mongolian', - en: 'Mongolian', android: false, ios: false, }, @@ -2527,7 +2224,6 @@ export const LANGUAGES: Language[] = [ code3: 'mos', code2: '', name: 'Mossi', - en: 'Mossi', android: false, ios: false, }, @@ -2535,15 +2231,13 @@ export const LANGUAGES: Language[] = [ code3: 'mri', code2: 'mi', name: 'Māori', - en: 'Māori', android: false, ios: false, }, { code3: 'msa', code2: 'ms', - name: 'Melayu', - en: 'Malay', + name: 'Malay', android: true, ios: false, }, @@ -2551,7 +2245,6 @@ export const LANGUAGES: Language[] = [ code3: 'mul', code2: '', name: 'Multiple languages', - en: 'Multiple languages', android: false, ios: false, }, @@ -2559,7 +2252,6 @@ export const LANGUAGES: Language[] = [ code3: 'mun', code2: '', name: 'Munda languages', - en: 'Munda languages', android: false, ios: false, }, @@ -2567,7 +2259,6 @@ export const LANGUAGES: Language[] = [ code3: 'mus', code2: '', name: 'Creek', - en: 'Creek', android: false, ios: false, }, @@ -2575,7 +2266,6 @@ export const LANGUAGES: Language[] = [ code3: 'mwl', code2: '', name: 'Mirandese', - en: 'Mirandese', android: false, ios: false, }, @@ -2583,7 +2273,6 @@ export const LANGUAGES: Language[] = [ code3: 'mwr', code2: '', name: 'Marwari', - en: 'Marwari', android: false, ios: false, }, @@ -2591,7 +2280,6 @@ export const LANGUAGES: Language[] = [ code3: 'mya', code2: 'my', name: 'Burmese', - en: 'Burmese', android: false, ios: false, }, @@ -2599,7 +2287,6 @@ export const LANGUAGES: Language[] = [ code3: 'myn', code2: '', name: 'Mayan languages', - en: 'Mayan languages', android: false, ios: false, }, @@ -2607,7 +2294,6 @@ export const LANGUAGES: Language[] = [ code3: 'myv', code2: '', name: 'Erzya', - en: 'Erzya', android: false, ios: false, }, @@ -2615,7 +2301,6 @@ export const LANGUAGES: Language[] = [ code3: 'nah', code2: '', name: 'Nahuatl languages', - en: 'Nahuatl languages', android: false, ios: false, }, @@ -2623,7 +2308,6 @@ export const LANGUAGES: Language[] = [ code3: 'nai', code2: '', name: 'North American Indian languages', - en: 'North American Indian languages', android: false, ios: false, }, @@ -2631,7 +2315,6 @@ export const LANGUAGES: Language[] = [ code3: 'nap', code2: '', name: 'Neapolitan', - en: 'Neapolitan', android: false, ios: false, }, @@ -2639,7 +2322,6 @@ export const LANGUAGES: Language[] = [ code3: 'nau', code2: 'na', name: 'Nauru', - en: 'Nauru', android: false, ios: false, }, @@ -2647,7 +2329,6 @@ export const LANGUAGES: Language[] = [ code3: 'nav', code2: 'nv', name: 'Navajo', - en: 'Navajo', android: false, ios: false, }, @@ -2655,7 +2336,6 @@ export const LANGUAGES: Language[] = [ code3: 'nbl', code2: 'nr', name: 'South Ndebele', - en: 'South Ndebele', android: false, ios: false, }, @@ -2663,7 +2343,6 @@ export const LANGUAGES: Language[] = [ code3: 'nde', code2: 'nd', name: 'North Ndebele', - en: 'North Ndebele', android: false, ios: false, }, @@ -2671,7 +2350,6 @@ export const LANGUAGES: Language[] = [ code3: 'ndo', code2: 'ng', name: 'Ndonga', - en: 'Ndonga', android: false, ios: false, }, @@ -2679,7 +2357,6 @@ export const LANGUAGES: Language[] = [ code3: 'nds', code2: '', name: 'Low German; Low Saxon; German, Low; Saxon, Low', - en: 'Low German; Low Saxon; German, Low; Saxon, Low', android: false, ios: false, }, @@ -2687,7 +2364,6 @@ export const LANGUAGES: Language[] = [ code3: 'nep', code2: 'ne', name: 'Nepali', - en: 'Nepali', android: false, ios: false, }, @@ -2695,7 +2371,6 @@ export const LANGUAGES: Language[] = [ code3: 'new', code2: '', name: 'Nepal Bhasa; Newari', - en: 'Nepal Bhasa; Newari', android: false, ios: false, }, @@ -2703,7 +2378,6 @@ export const LANGUAGES: Language[] = [ code3: 'nia', code2: '', name: 'Nias', - en: 'Nias', android: false, ios: false, }, @@ -2711,7 +2385,6 @@ export const LANGUAGES: Language[] = [ code3: 'nic', code2: '', name: 'Niger-Kordofanian languages', - en: 'Niger-Kordofanian languages', android: false, ios: false, }, @@ -2719,15 +2392,13 @@ export const LANGUAGES: Language[] = [ code3: 'niu', code2: '', name: 'Niuean', - en: 'Niuean', android: false, ios: false, }, { code3: 'nld', code2: 'nl', - name: 'Nederlands', - en: 'Dutch', + name: 'Dutch', android: true, ios: true, }, @@ -2735,15 +2406,13 @@ export const LANGUAGES: Language[] = [ code3: 'nno', code2: 'nn', name: 'Norwegian Nynorsk', - en: 'Norwegian Nynorsk', android: false, ios: false, }, { code3: 'nob', code2: 'nb', - name: 'norsk bokmål', - en: 'Norwegian Bokmål', + name: 'Norwegian Bokmål', android: false, ios: false, }, @@ -2751,7 +2420,6 @@ export const LANGUAGES: Language[] = [ code3: 'nog', code2: '', name: 'Nogai', - en: 'Nogai', android: false, ios: false, }, @@ -2759,15 +2427,13 @@ export const LANGUAGES: Language[] = [ code3: 'non', code2: '', name: 'Norse, Old', - en: 'Norse, Old', android: false, ios: false, }, { code3: 'nor', code2: 'no', - name: 'norsk', - en: 'Norwegian', + name: 'Norwegian', android: true, ios: false, }, @@ -2775,7 +2441,6 @@ export const LANGUAGES: Language[] = [ code3: 'nqo', code2: '', name: "N'Ko", - en: "N'Ko", android: false, ios: false, }, @@ -2783,7 +2448,6 @@ export const LANGUAGES: Language[] = [ code3: 'nso', code2: '', name: 'Northern Sotho', - en: 'Northern Sotho', android: false, ios: false, }, @@ -2791,7 +2455,6 @@ export const LANGUAGES: Language[] = [ code3: 'nub', code2: '', name: 'Nubian languages', - en: 'Nubian languages', android: false, ios: false, }, @@ -2799,7 +2462,6 @@ export const LANGUAGES: Language[] = [ code3: 'nwc', code2: '', name: 'Classical Newari; Old Newari; Classical Nepal Bhasa', - en: 'Classical Newari; Old Newari; Classical Nepal Bhasa', android: false, ios: false, }, @@ -2807,7 +2469,6 @@ export const LANGUAGES: Language[] = [ code3: 'nya', code2: 'ny', name: 'Nyanja', - en: 'Nyanja', android: false, ios: false, }, @@ -2815,7 +2476,6 @@ export const LANGUAGES: Language[] = [ code3: 'nym', code2: '', name: 'Nyamwezi', - en: 'Nyamwezi', android: false, ios: false, }, @@ -2823,7 +2483,6 @@ export const LANGUAGES: Language[] = [ code3: 'nyn', code2: '', name: 'Nyankole', - en: 'Nyankole', android: false, ios: false, }, @@ -2831,7 +2490,6 @@ export const LANGUAGES: Language[] = [ code3: 'nyo', code2: '', name: 'Nyoro', - en: 'Nyoro', android: false, ios: false, }, @@ -2839,7 +2497,6 @@ export const LANGUAGES: Language[] = [ code3: 'nzi', code2: '', name: 'Nzima', - en: 'Nzima', android: false, ios: false, }, @@ -2847,7 +2504,6 @@ export const LANGUAGES: Language[] = [ code3: 'oci', code2: 'oc', name: 'Occitan', - en: 'Occitan', android: false, ios: false, }, @@ -2855,7 +2511,6 @@ export const LANGUAGES: Language[] = [ code3: 'oji', code2: 'oj', name: 'Ojibwa', - en: 'Ojibwa', android: false, ios: false, }, @@ -2863,7 +2518,6 @@ export const LANGUAGES: Language[] = [ code3: 'ori', code2: 'or', name: 'Odia', - en: 'Odia', android: false, ios: false, }, @@ -2871,7 +2525,6 @@ export const LANGUAGES: Language[] = [ code3: 'orm', code2: 'om', name: 'Oromo', - en: 'Oromo', android: false, ios: false, }, @@ -2879,7 +2532,6 @@ export const LANGUAGES: Language[] = [ code3: 'osa', code2: '', name: 'Osage', - en: 'Osage', android: false, ios: false, }, @@ -2887,7 +2539,6 @@ export const LANGUAGES: Language[] = [ code3: 'oss', code2: 'os', name: 'Ossetic', - en: 'Ossetic', android: false, ios: false, }, @@ -2895,7 +2546,6 @@ export const LANGUAGES: Language[] = [ code3: 'ota', code2: '', name: 'Turkish, Ottoman (1500-1928)', - en: 'Turkish, Ottoman (1500-1928)', android: false, ios: false, }, @@ -2903,7 +2553,6 @@ export const LANGUAGES: Language[] = [ code3: 'oto', code2: '', name: 'Otomian languages', - en: 'Otomian languages', android: false, ios: false, }, @@ -2911,7 +2560,6 @@ export const LANGUAGES: Language[] = [ code3: 'paa', code2: '', name: 'Papuan languages', - en: 'Papuan languages', android: false, ios: false, }, @@ -2919,7 +2567,6 @@ export const LANGUAGES: Language[] = [ code3: 'pag', code2: '', name: 'Pangasinan', - en: 'Pangasinan', android: false, ios: false, }, @@ -2927,7 +2574,6 @@ export const LANGUAGES: Language[] = [ code3: 'pal', code2: '', name: 'Pahlavi', - en: 'Pahlavi', android: false, ios: false, }, @@ -2935,15 +2581,13 @@ export const LANGUAGES: Language[] = [ code3: 'pam', code2: '', name: 'Pampanga; Kapampangan', - en: 'Pampanga; Kapampangan', android: false, ios: false, }, { code3: 'pan', code2: 'pa', - name: 'ਪੰਜਾਬੀ', - en: 'Punjabi', + name: 'Punjabi', android: false, ios: false, }, @@ -2951,7 +2595,6 @@ export const LANGUAGES: Language[] = [ code3: 'pap', code2: '', name: 'Papiamento', - en: 'Papiamento', android: false, ios: false, }, @@ -2959,7 +2602,6 @@ export const LANGUAGES: Language[] = [ code3: 'pau', code2: '', name: 'Palauan', - en: 'Palauan', android: false, ios: false, }, @@ -2967,15 +2609,13 @@ export const LANGUAGES: Language[] = [ code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)', - en: 'Persian, Old (ca.600-400 B.C.)', android: false, ios: false, }, { code3: 'per', code2: 'fa', - name: 'فارسی', - en: 'Persian', + name: 'Persian', android: true, ios: false, }, @@ -2983,7 +2623,6 @@ export const LANGUAGES: Language[] = [ code3: 'phi', code2: '', name: 'Philippine languages', - en: 'Philippine languages', android: false, ios: false, }, @@ -2991,7 +2630,6 @@ export const LANGUAGES: Language[] = [ code3: 'phn', code2: '', name: 'Phoenician', - en: 'Phoenician', android: false, ios: false, }, @@ -2999,15 +2637,13 @@ export const LANGUAGES: Language[] = [ code3: 'pli', code2: 'pi', name: 'Pali', - en: 'Pali', android: false, ios: false, }, { code3: 'pol', code2: 'pl', - name: 'polski', - en: 'Polish', + name: 'Polish', android: true, ios: true, }, @@ -3015,15 +2651,13 @@ export const LANGUAGES: Language[] = [ code3: 'pon', code2: '', name: 'Pohnpeian', - en: 'Pohnpeian', android: false, ios: false, }, { code3: 'por', code2: 'pt', - name: 'português', - en: 'Portuguese', + name: 'Portuguese', android: true, ios: true, }, @@ -3031,7 +2665,6 @@ export const LANGUAGES: Language[] = [ code3: 'pra', code2: '', name: 'Prakrit languages', - en: 'Prakrit languages', android: false, ios: false, }, @@ -3039,7 +2672,6 @@ export const LANGUAGES: Language[] = [ code3: 'pro', code2: '', name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)', - en: 'Provençal, Old (to 1500);Occitan, Old (to 1500)', android: false, ios: false, }, @@ -3047,7 +2679,6 @@ export const LANGUAGES: Language[] = [ code3: 'pus', code2: 'ps', name: 'Pashto', - en: 'Pashto', android: false, ios: false, }, @@ -3055,7 +2686,6 @@ export const LANGUAGES: Language[] = [ code3: 'que', code2: 'qu', name: 'Quechua', - en: 'Quechua', android: false, ios: false, }, @@ -3063,7 +2693,6 @@ export const LANGUAGES: Language[] = [ code3: 'raj', code2: '', name: 'Rajasthani', - en: 'Rajasthani', android: false, ios: false, }, @@ -3071,7 +2700,6 @@ export const LANGUAGES: Language[] = [ code3: 'rap', code2: '', name: 'Rapanui', - en: 'Rapanui', android: false, ios: false, }, @@ -3079,7 +2707,6 @@ export const LANGUAGES: Language[] = [ code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori', - en: 'Rarotongan; Cook Islands Maori', android: false, ios: false, }, @@ -3087,7 +2714,6 @@ export const LANGUAGES: Language[] = [ code3: 'roa', code2: '', name: 'Romance languages', - en: 'Romance languages', android: false, ios: false, }, @@ -3095,7 +2721,6 @@ export const LANGUAGES: Language[] = [ code3: 'roh', code2: 'rm', name: 'Romansh', - en: 'Romansh', android: false, ios: false, }, @@ -3103,23 +2728,20 @@ export const LANGUAGES: Language[] = [ code3: 'rom', code2: '', name: 'Romany', - en: 'Romany', android: false, ios: false, }, { code3: 'rum', code2: 'ro', - name: 'română', - en: 'Romanian', + name: 'Romanian', android: true, ios: false, }, { code3: 'ron', code2: 'ro', - name: 'română', - en: 'Romanian', + name: 'Romanian', android: true, ios: false, }, @@ -3127,7 +2749,6 @@ export const LANGUAGES: Language[] = [ code3: 'run', code2: 'rn', name: 'Rundi', - en: 'Rundi', android: false, ios: false, }, @@ -3135,15 +2756,13 @@ export const LANGUAGES: Language[] = [ code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian', - en: 'Aromanian; Arumanian; Macedo-Romanian', android: false, ios: false, }, { code3: 'rus', code2: 'ru', - name: 'русский', - en: 'Russian', + name: 'Russian', android: true, ios: true, }, @@ -3151,7 +2770,6 @@ export const LANGUAGES: Language[] = [ code3: 'sad', code2: '', name: 'Sandawe', - en: 'Sandawe', android: false, ios: false, }, @@ -3159,7 +2777,6 @@ export const LANGUAGES: Language[] = [ code3: 'sag', code2: 'sg', name: 'Sango', - en: 'Sango', android: false, ios: false, }, @@ -3167,7 +2784,6 @@ export const LANGUAGES: Language[] = [ code3: 'sah', code2: '', name: 'Yakut', - en: 'Yakut', android: false, ios: false, }, @@ -3175,7 +2791,6 @@ export const LANGUAGES: Language[] = [ code3: 'sai', code2: '', name: 'South American Indian languages', - en: 'South American Indian languages', android: false, ios: false, }, @@ -3183,7 +2798,6 @@ export const LANGUAGES: Language[] = [ code3: 'sal', code2: '', name: 'Salishan languages', - en: 'Salishan languages', android: false, ios: false, }, @@ -3191,7 +2805,6 @@ export const LANGUAGES: Language[] = [ code3: 'sam', code2: '', name: 'Samaritan Aramaic', - en: 'Samaritan Aramaic', android: false, ios: false, }, @@ -3199,7 +2812,6 @@ export const LANGUAGES: Language[] = [ code3: 'san', code2: 'sa', name: 'Sanskrit', - en: 'Sanskrit', android: false, ios: false, }, @@ -3207,7 +2819,6 @@ export const LANGUAGES: Language[] = [ code3: 'sas', code2: '', name: 'Sasak', - en: 'Sasak', android: false, ios: false, }, @@ -3215,7 +2826,6 @@ export const LANGUAGES: Language[] = [ code3: 'sat', code2: '', name: 'Santali', - en: 'Santali', android: false, ios: false, }, @@ -3223,7 +2833,6 @@ export const LANGUAGES: Language[] = [ code3: 'scn', code2: '', name: 'Sicilian', - en: 'Sicilian', android: false, ios: false, }, @@ -3231,7 +2840,6 @@ export const LANGUAGES: Language[] = [ code3: 'sco', code2: '', name: 'Scots', - en: 'Scots', android: false, ios: false, }, @@ -3239,7 +2847,6 @@ export const LANGUAGES: Language[] = [ code3: 'sel', code2: '', name: 'Selkup', - en: 'Selkup', android: false, ios: false, }, @@ -3247,7 +2854,6 @@ export const LANGUAGES: Language[] = [ code3: 'sem', code2: '', name: 'Semitic languages', - en: 'Semitic languages', android: false, ios: false, }, @@ -3255,7 +2861,6 @@ export const LANGUAGES: Language[] = [ code3: 'sga', code2: '', name: 'Irish, Old (to 900)', - en: 'Irish, Old (to 900)', android: false, ios: false, }, @@ -3263,7 +2868,6 @@ export const LANGUAGES: Language[] = [ code3: 'sgn', code2: '', name: 'Sign Languages', - en: 'Sign Languages', android: false, ios: false, }, @@ -3271,7 +2875,6 @@ export const LANGUAGES: Language[] = [ code3: 'shn', code2: '', name: 'Shan', - en: 'Shan', android: false, ios: false, }, @@ -3279,7 +2882,6 @@ export const LANGUAGES: Language[] = [ code3: 'sid', code2: '', name: 'Sidamo', - en: 'Sidamo', android: false, ios: false, }, @@ -3287,7 +2889,6 @@ export const LANGUAGES: Language[] = [ code3: 'sin', code2: 'si', name: 'Sinhala', - en: 'Sinhala', android: false, ios: false, }, @@ -3295,7 +2896,6 @@ export const LANGUAGES: Language[] = [ code3: 'sio', code2: '', name: 'Siouan languages', - en: 'Siouan languages', android: false, ios: false, }, @@ -3303,7 +2903,6 @@ export const LANGUAGES: Language[] = [ code3: 'sit', code2: '', name: 'Sino-Tibetan languages', - en: 'Sino-Tibetan languages', android: false, ios: false, }, @@ -3311,31 +2910,27 @@ export const LANGUAGES: Language[] = [ code3: 'sla', code2: '', name: 'Slavic languages', - en: 'Slavic languages', android: false, ios: false, }, { code3: 'slo', code2: 'sk', - name: 'slovenčina', - en: 'Slovak', + name: 'Slovak', android: true, ios: false, }, { code3: 'slk', code2: 'sk', - name: 'slovenčina', - en: 'Slovak', + name: 'Slovak', android: true, ios: false, }, { code3: 'slv', code2: 'sl', - name: 'slovenščina', - en: 'Slovenian', + name: 'Slovenian', android: true, ios: false, }, @@ -3343,7 +2938,6 @@ export const LANGUAGES: Language[] = [ code3: 'sma', code2: '', name: 'Southern Sami', - en: 'Southern Sami', android: false, ios: false, }, @@ -3351,7 +2945,6 @@ export const LANGUAGES: Language[] = [ code3: 'sme', code2: 'se', name: 'Northern Sami', - en: 'Northern Sami', android: false, ios: false, }, @@ -3359,7 +2952,6 @@ export const LANGUAGES: Language[] = [ code3: 'smi', code2: '', name: 'Sami languages', - en: 'Sami languages', android: false, ios: false, }, @@ -3367,7 +2959,6 @@ export const LANGUAGES: Language[] = [ code3: 'smj', code2: '', name: 'Lule Sami', - en: 'Lule Sami', android: false, ios: false, }, @@ -3375,7 +2966,6 @@ export const LANGUAGES: Language[] = [ code3: 'smn', code2: '', name: 'Inari Sami', - en: 'Inari Sami', android: false, ios: false, }, @@ -3383,7 +2973,6 @@ export const LANGUAGES: Language[] = [ code3: 'smo', code2: 'sm', name: 'Samoan', - en: 'Samoan', android: false, ios: false, }, @@ -3391,7 +2980,6 @@ export const LANGUAGES: Language[] = [ code3: 'sms', code2: '', name: 'Skolt Sami', - en: 'Skolt Sami', android: false, ios: false, }, @@ -3399,15 +2987,13 @@ export const LANGUAGES: Language[] = [ code3: 'sna', code2: 'sn', name: 'Shona', - en: 'Shona', android: false, ios: false, }, { code3: 'snd', code2: 'sd', - name: 'سنڌي', - en: 'Sindhi', + name: 'Sindhi', android: false, ios: false, }, @@ -3415,7 +3001,6 @@ export const LANGUAGES: Language[] = [ code3: 'snk', code2: '', name: 'Soninke', - en: 'Soninke', android: false, ios: false, }, @@ -3423,7 +3008,6 @@ export const LANGUAGES: Language[] = [ code3: 'sog', code2: '', name: 'Sogdian', - en: 'Sogdian', android: false, ios: false, }, @@ -3431,7 +3015,6 @@ export const LANGUAGES: Language[] = [ code3: 'som', code2: 'so', name: 'Somali', - en: 'Somali', android: false, ios: false, }, @@ -3439,7 +3022,6 @@ export const LANGUAGES: Language[] = [ code3: 'son', code2: '', name: 'Songhai languages', - en: 'Songhai languages', android: false, ios: false, }, @@ -3447,15 +3029,13 @@ export const LANGUAGES: Language[] = [ code3: 'sot', code2: 'st', name: 'Southern Sotho', - en: 'Southern Sotho', android: false, ios: false, }, { code3: 'spa', code2: 'es', - name: 'español', - en: 'Spanish', + name: 'Spanish', android: true, ios: true, }, @@ -3463,7 +3043,6 @@ export const LANGUAGES: Language[] = [ code3: 'sqi', code2: 'sq', name: 'Albanian', - en: 'Albanian', android: true, ios: false, }, @@ -3471,7 +3050,6 @@ export const LANGUAGES: Language[] = [ code3: 'srd', code2: 'sc', name: 'Sardinian', - en: 'Sardinian', android: false, ios: false, }, @@ -3479,15 +3057,13 @@ export const LANGUAGES: Language[] = [ code3: 'srn', code2: '', name: 'Sranan Tongo', - en: 'Sranan Tongo', android: false, ios: false, }, { code3: 'srp', code2: 'sr', - name: 'српски', - en: 'Serbian', + name: 'Serbian', android: false, ios: false, }, @@ -3495,7 +3071,6 @@ export const LANGUAGES: Language[] = [ code3: 'srr', code2: '', name: 'Serer', - en: 'Serer', android: false, ios: false, }, @@ -3503,7 +3078,6 @@ export const LANGUAGES: Language[] = [ code3: 'ssa', code2: '', name: 'Nilo-Saharan languages', - en: 'Nilo-Saharan languages', android: false, ios: false, }, @@ -3511,7 +3085,6 @@ export const LANGUAGES: Language[] = [ code3: 'ssw', code2: 'ss', name: 'Swati', - en: 'Swati', android: false, ios: false, }, @@ -3519,7 +3092,6 @@ export const LANGUAGES: Language[] = [ code3: 'suk', code2: '', name: 'Sukuma', - en: 'Sukuma', android: false, ios: false, }, @@ -3527,7 +3099,6 @@ export const LANGUAGES: Language[] = [ code3: 'sun', code2: 'su', name: 'Sundanese', - en: 'Sundanese', android: false, ios: false, }, @@ -3535,7 +3106,6 @@ export const LANGUAGES: Language[] = [ code3: 'sus', code2: '', name: 'Susu', - en: 'Susu', android: false, ios: false, }, @@ -3543,23 +3113,20 @@ export const LANGUAGES: Language[] = [ code3: 'sux', code2: '', name: 'Sumerian', - en: 'Sumerian', android: false, ios: false, }, { code3: 'swa', code2: 'sw', - name: 'Kiswahili', - en: 'Swahili', + name: 'Swahili', android: true, ios: false, }, { code3: 'swe', code2: 'sv', - name: 'svenska', - en: 'Swedish', + name: 'Swedish', android: true, ios: false, }, @@ -3567,7 +3134,6 @@ export const LANGUAGES: Language[] = [ code3: 'syc', code2: '', name: 'Classical Syriac', - en: 'Classical Syriac', android: false, ios: false, }, @@ -3575,7 +3141,6 @@ export const LANGUAGES: Language[] = [ code3: 'syr', code2: '', name: 'Syriac', - en: 'Syriac', android: false, ios: false, }, @@ -3583,7 +3148,6 @@ export const LANGUAGES: Language[] = [ code3: 'tah', code2: 'ty', name: 'Tahitian', - en: 'Tahitian', android: false, ios: false, }, @@ -3591,15 +3155,13 @@ export const LANGUAGES: Language[] = [ code3: 'tai', code2: '', name: 'Tai languages', - en: 'Tai languages', android: false, ios: false, }, { code3: 'tam', code2: 'ta', - name: 'தமிழ்', - en: 'Tamil', + name: 'Tamil', android: true, ios: false, }, @@ -3607,15 +3169,13 @@ export const LANGUAGES: Language[] = [ code3: 'tat', code2: 'tt', name: 'Tatar', - en: 'Tatar', android: false, ios: false, }, { code3: 'tel', code2: 'te', - name: 'తెలుగు', - en: 'Telugu', + name: 'Telugu', android: true, ios: false, }, @@ -3623,7 +3183,6 @@ export const LANGUAGES: Language[] = [ code3: 'tem', code2: '', name: 'Timne', - en: 'Timne', android: false, ios: false, }, @@ -3631,7 +3190,6 @@ export const LANGUAGES: Language[] = [ code3: 'ter', code2: '', name: 'Tereno', - en: 'Tereno', android: false, ios: false, }, @@ -3639,7 +3197,6 @@ export const LANGUAGES: Language[] = [ code3: 'tet', code2: '', name: 'Tetum', - en: 'Tetum', android: false, ios: false, }, @@ -3647,7 +3204,6 @@ export const LANGUAGES: Language[] = [ code3: 'tgk', code2: 'tg', name: 'Tajik', - en: 'Tajik', android: false, ios: false, }, @@ -3655,15 +3211,13 @@ export const LANGUAGES: Language[] = [ code3: 'tgl', code2: 'tl', name: 'Filipino', - en: 'Filipino', android: true, ios: false, }, { code3: 'tha', code2: 'th', - name: 'ไทย', - en: 'Thai', + name: 'Thai', android: true, ios: true, }, @@ -3671,7 +3225,6 @@ export const LANGUAGES: Language[] = [ code3: 'tib', code2: 'bo', name: 'Tibetan', - en: 'Tibetan', android: false, ios: false, }, @@ -3679,7 +3232,6 @@ export const LANGUAGES: Language[] = [ code3: 'tig', code2: '', name: 'Tigre', - en: 'Tigre', android: false, ios: false, }, @@ -3687,7 +3239,6 @@ export const LANGUAGES: Language[] = [ code3: 'tir', code2: 'ti', name: 'Tigrinya', - en: 'Tigrinya', android: false, ios: false, }, @@ -3695,7 +3246,6 @@ export const LANGUAGES: Language[] = [ code3: 'tiv', code2: '', name: 'Tiv', - en: 'Tiv', android: false, ios: false, }, @@ -3703,7 +3253,6 @@ export const LANGUAGES: Language[] = [ code3: 'tkl', code2: '', name: 'Tokelau', - en: 'Tokelau', android: false, ios: false, }, @@ -3711,7 +3260,6 @@ export const LANGUAGES: Language[] = [ code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol', - en: 'Klingon; tlhIngan-Hol', android: false, ios: false, }, @@ -3719,7 +3267,6 @@ export const LANGUAGES: Language[] = [ code3: 'tli', code2: '', name: 'Tlingit', - en: 'Tlingit', android: false, ios: false, }, @@ -3727,7 +3274,6 @@ export const LANGUAGES: Language[] = [ code3: 'tmh', code2: '', name: 'Tamashek', - en: 'Tamashek', android: false, ios: false, }, @@ -3735,7 +3281,6 @@ export const LANGUAGES: Language[] = [ code3: 'tog', code2: '', name: 'Tonga (Nyasa)', - en: 'Tonga (Nyasa)', android: false, ios: false, }, @@ -3743,7 +3288,6 @@ export const LANGUAGES: Language[] = [ code3: 'ton', code2: 'to', name: 'Tongan', - en: 'Tongan', android: false, ios: false, }, @@ -3751,7 +3295,6 @@ export const LANGUAGES: Language[] = [ code3: 'tpi', code2: '', name: 'Tok Pisin', - en: 'Tok Pisin', android: false, ios: false, }, @@ -3759,7 +3302,6 @@ export const LANGUAGES: Language[] = [ code3: 'tsi', code2: '', name: 'Tsimshian', - en: 'Tsimshian', android: false, ios: false, }, @@ -3767,7 +3309,6 @@ export const LANGUAGES: Language[] = [ code3: 'tsn', code2: 'tn', name: 'Tswana', - en: 'Tswana', android: false, ios: false, }, @@ -3775,7 +3316,6 @@ export const LANGUAGES: Language[] = [ code3: 'tso', code2: 'ts', name: 'Tsonga', - en: 'Tsonga', android: false, ios: false, }, @@ -3783,7 +3323,6 @@ export const LANGUAGES: Language[] = [ code3: 'tuk', code2: 'tk', name: 'Turkmen', - en: 'Turkmen', android: false, ios: false, }, @@ -3791,7 +3330,6 @@ export const LANGUAGES: Language[] = [ code3: 'tum', code2: '', name: 'Tumbuka', - en: 'Tumbuka', android: false, ios: false, }, @@ -3799,15 +3337,13 @@ export const LANGUAGES: Language[] = [ code3: 'tup', code2: '', name: 'Tupi languages', - en: 'Tupi languages', android: false, ios: false, }, { code3: 'tur', code2: 'tr', - name: 'Türkçe', - en: 'Turkish', + name: 'Turkish', android: true, ios: true, }, @@ -3815,7 +3351,6 @@ export const LANGUAGES: Language[] = [ code3: 'tut', code2: '', name: 'Altaic languages', - en: 'Altaic languages', android: false, ios: false, }, @@ -3823,7 +3358,6 @@ export const LANGUAGES: Language[] = [ code3: 'tvl', code2: '', name: 'Tuvalu', - en: 'Tuvalu', android: false, ios: false, }, @@ -3831,7 +3365,6 @@ export const LANGUAGES: Language[] = [ code3: 'twi', code2: 'tw', name: 'Akan', - en: 'Akan', android: false, ios: false, }, @@ -3839,7 +3372,6 @@ export const LANGUAGES: Language[] = [ code3: 'tyv', code2: '', name: 'Tuvinian', - en: 'Tuvinian', android: false, ios: false, }, @@ -3847,7 +3379,6 @@ export const LANGUAGES: Language[] = [ code3: 'udm', code2: '', name: 'Udmurt', - en: 'Udmurt', android: false, ios: false, }, @@ -3855,7 +3386,6 @@ export const LANGUAGES: Language[] = [ code3: 'uga', code2: '', name: 'Ugaritic', - en: 'Ugaritic', android: false, ios: false, }, @@ -3863,15 +3393,13 @@ export const LANGUAGES: Language[] = [ code3: 'uig', code2: 'ug', name: 'Uyghur', - en: 'Uyghur', android: false, ios: false, }, { code3: 'ukr', code2: 'uk', - name: 'українська', - en: 'Ukrainian', + name: 'Ukrainian', android: true, ios: true, }, @@ -3879,7 +3407,6 @@ export const LANGUAGES: Language[] = [ code3: 'umb', code2: '', name: 'Umbundu', - en: 'Umbundu', android: false, ios: false, }, @@ -3887,23 +3414,20 @@ export const LANGUAGES: Language[] = [ code3: 'und', code2: '', name: 'Undetermined', - en: 'Undetermined', android: false, ios: false, }, { code3: 'urd', code2: 'ur', - name: 'اردو', - en: 'Urdu', + name: 'Urdu', android: true, ios: false, }, { code3: 'uzb', code2: 'uz', - name: 'o‘zbek', - en: 'Uzbek', + name: 'Uzbek', android: false, ios: false, }, @@ -3911,7 +3435,6 @@ export const LANGUAGES: Language[] = [ code3: 'vai', code2: '', name: 'Vai', - en: 'Vai', android: false, ios: false, }, @@ -3919,15 +3442,13 @@ export const LANGUAGES: Language[] = [ code3: 'ven', code2: 've', name: 'Venda', - en: 'Venda', android: false, ios: false, }, { code3: 'vie', code2: 'vi', - name: 'Tiếng Việt', - en: 'Vietnamese', + name: 'Vietnamese', android: true, ios: true, }, @@ -3935,7 +3456,6 @@ export const LANGUAGES: Language[] = [ code3: 'vol', code2: 'vo', name: 'Volapük', - en: 'Volapük', android: false, ios: false, }, @@ -3943,7 +3463,6 @@ export const LANGUAGES: Language[] = [ code3: 'vot', code2: '', name: 'Votic', - en: 'Votic', android: false, ios: false, }, @@ -3951,7 +3470,6 @@ export const LANGUAGES: Language[] = [ code3: 'wak', code2: '', name: 'Wakashan languages', - en: 'Wakashan languages', android: false, ios: false, }, @@ -3959,7 +3477,6 @@ export const LANGUAGES: Language[] = [ code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta', - en: 'Wolaitta; Wolaytta', android: false, ios: false, }, @@ -3967,7 +3484,6 @@ export const LANGUAGES: Language[] = [ code3: 'war', code2: '', name: 'Waray', - en: 'Waray', android: false, ios: false, }, @@ -3975,7 +3491,6 @@ export const LANGUAGES: Language[] = [ code3: 'was', code2: '', name: 'Washo', - en: 'Washo', android: false, ios: false, }, @@ -3983,7 +3498,6 @@ export const LANGUAGES: Language[] = [ code3: 'wel', code2: 'cy', name: 'Welsh', - en: 'Welsh', android: true, ios: false, }, @@ -3991,7 +3505,6 @@ export const LANGUAGES: Language[] = [ code3: 'wen', code2: '', name: 'Sorbian languages', - en: 'Sorbian languages', android: false, ios: false, }, @@ -3999,7 +3512,6 @@ export const LANGUAGES: Language[] = [ code3: 'wln', code2: 'wa', name: 'Walloon', - en: 'Walloon', android: false, ios: false, }, @@ -4007,7 +3519,6 @@ export const LANGUAGES: Language[] = [ code3: 'wol', code2: 'wo', name: 'Wolof', - en: 'Wolof', android: false, ios: false, }, @@ -4015,7 +3526,6 @@ export const LANGUAGES: Language[] = [ code3: 'xal', code2: '', name: 'Kalmyk; Oirat', - en: 'Kalmyk; Oirat', android: false, ios: false, }, @@ -4023,7 +3533,6 @@ export const LANGUAGES: Language[] = [ code3: 'xho', code2: 'xh', name: 'Xhosa', - en: 'Xhosa', android: false, ios: false, }, @@ -4031,7 +3540,6 @@ export const LANGUAGES: Language[] = [ code3: 'yao', code2: '', name: 'Yao', - en: 'Yao', android: false, ios: false, }, @@ -4039,7 +3547,6 @@ export const LANGUAGES: Language[] = [ code3: 'yap', code2: '', name: 'Yapese', - en: 'Yapese', android: false, ios: false, }, @@ -4047,7 +3554,6 @@ export const LANGUAGES: Language[] = [ code3: 'yid', code2: 'yi', name: 'Yiddish', - en: 'Yiddish', android: false, ios: false, }, @@ -4055,7 +3561,6 @@ export const LANGUAGES: Language[] = [ code3: 'yor', code2: 'yo', name: 'Yoruba', - en: 'Yoruba', android: false, ios: false, }, @@ -4063,7 +3568,6 @@ export const LANGUAGES: Language[] = [ code3: 'ypk', code2: '', name: 'Yupik languages', - en: 'Yupik languages', android: false, ios: false, }, @@ -4071,7 +3575,6 @@ export const LANGUAGES: Language[] = [ code3: 'zap', code2: '', name: 'Zapotec', - en: 'Zapotec', android: false, ios: false, }, @@ -4079,7 +3582,6 @@ export const LANGUAGES: Language[] = [ code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss', - en: 'Blissymbols; Blissymbolics; Bliss', android: false, ios: false, }, @@ -4087,7 +3589,6 @@ export const LANGUAGES: Language[] = [ code3: 'zen', code2: '', name: 'Zenaga', - en: 'Zenaga', android: false, ios: false, }, @@ -4095,7 +3596,6 @@ export const LANGUAGES: Language[] = [ code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight', - en: 'Standard Moroccan Tamazight', android: false, ios: false, }, @@ -4103,15 +3603,13 @@ export const LANGUAGES: Language[] = [ code3: 'zha', code2: 'za', name: 'Zhuang; Chuang', - en: 'Zhuang; Chuang', android: false, ios: false, }, { code3: 'zho', code2: 'zh', - name: '中文', - en: 'Chinese', + name: 'Chinese', android: true, ios: true, }, @@ -4119,7 +3617,6 @@ export const LANGUAGES: Language[] = [ code3: 'znd', code2: '', name: 'Zande languages', - en: 'Zande languages', android: false, ios: false, }, @@ -4127,7 +3624,6 @@ export const LANGUAGES: Language[] = [ code3: 'zul', code2: 'zu', name: 'Zulu', - en: 'Zulu', android: false, ios: false, }, @@ -4135,7 +3631,6 @@ export const LANGUAGES: Language[] = [ code3: 'zun', code2: '', name: 'Zuni', - en: 'Zuni', android: false, ios: false, }, @@ -4143,7 +3638,6 @@ export const LANGUAGES: Language[] = [ code3: 'zza', code2: '', name: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', - en: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', android: false, ios: false, }, diff --git a/src/screens/Settings/LanguageSettings.tsx b/src/screens/Settings/LanguageSettings.tsx index 9ab8590ff..fcb9043e5 100644 --- a/src/screens/Settings/LanguageSettings.tsx +++ b/src/screens/Settings/LanguageSettings.tsx @@ -8,7 +8,7 @@ import { type CommonNavigatorParams, type NativeStackScreenProps, } from '#/lib/routes/types' -import {sanitizeAppLanguageSetting} from '#/locale/helpers' +import {languageName, sanitizeAppLanguageSetting} from '#/locale/helpers' import {APP_LANGUAGES, LANGUAGES} from '#/locale/languages' import {useLanguagePrefs, useLanguagePrefsApi} from '#/state/preferences' import {atoms as a, web} from '#/alf' @@ -144,13 +144,12 @@ export function LanguageSettingsScreen({}: Props) { {label} )} - items={DEDUPED_LANGUAGES.sort( - (a, b) => - a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort - ).map(l => ({ - label: l.name, // Pre-generated name using Intl.DisplayNames + items={DEDUPED_LANGUAGES.map(l => ({ + label: languageName(l, langPrefs.appLanguage), value: l.code2, - }))} + })).sort((a, b) => + a.label.localeCompare(b.label, langPrefs.appLanguage), + )} />
@@ -180,29 +179,24 @@ export function LanguageSettingsScreen({}: Props) { values={langPrefs.contentLanguages} onChange={setLangPrefs.setContentLanguages}> - {possibleLanguages - .sort( - (a, b) => - a.name.localeCompare(b.name, langPrefs.appLanguage), // Localized sort + {possibleLanguages.map((language, index) => { + const name = languageName(language, langPrefs.appLanguage) + return ( + + {({selected}) => ( + + + {name} + + )} + ) - .map((language, index) => { - const name = language.name // Pre-generated name using Intl.DisplayNames - return ( - - {({selected}) => ( - - - {name} - - )} - - ) - })} + })} -- 2.51.2 From c2fd87bd8b02c71dea47264e7c3057db1542bfdc Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Mar 2026 20:23:07 +0000 Subject: [PATCH 29/43] Remove JPG hardcoding from image processing systems (#9955) --- bskyogcard/src/components/Img.tsx | 13 +++- jest/jestSetup.js | 2 + src/lib/media/manip.ts | 71 +++++++++++--------- src/screens/Onboarding/StepProfile/index.tsx | 47 +++++++------ 4 files changed, 81 insertions(+), 52 deletions(-) diff --git a/bskyogcard/src/components/Img.tsx b/bskyogcard/src/components/Img.tsx index dac223180..733e7e226 100644 --- a/bskyogcard/src/components/Img.tsx +++ b/bskyogcard/src/components/Img.tsx @@ -1,10 +1,21 @@ import React from 'react' +function detectMime(buf: Buffer): string { + if (buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg' + if (buf[0] === 0x89 && buf[1] === 0x50) return 'image/png' + if (buf[0] === 0x52 && buf[1] === 0x49) return 'image/webp' + if (buf[0] === 0x47 && buf[1] === 0x49) return 'image/gif' + return 'image/jpeg' +} + export function Img( props: Omit, 'src'> & {src: Buffer}, ) { const {src, ...others} = props return ( - + ) } diff --git a/jest/jestSetup.js b/jest/jestSetup.js index c839d9e53..f9bc36f6b 100644 --- a/jest/jestSetup.js +++ b/jest/jestSetup.js @@ -34,6 +34,7 @@ jest.mock('react-native-safe-area-context', () => { jest.mock('expo-file-system/legacy', () => ({ getInfoAsync: jest.fn().mockResolvedValue({exists: true, size: 100}), deleteAsync: jest.fn(), + moveAsync: jest.fn().mockResolvedValue(undefined), createDownloadResumable: jest.fn(), })) @@ -43,6 +44,7 @@ jest.mock('expo-image-manipulator', () => ({ }), SaveFormat: { JPEG: 'jpeg', + WEBP: 'webp', }, })) diff --git a/src/lib/media/manip.ts b/src/lib/media/manip.ts index aa48a3a52..0cc92f446 100644 --- a/src/lib/media/manip.ts +++ b/src/lib/media/manip.ts @@ -8,6 +8,7 @@ import { EncodingType, getInfoAsync, makeDirectoryAsync, + moveAsync, StorageAccessFramework, writeAsStringAsync, } from 'expo-file-system/legacy' @@ -56,25 +57,19 @@ export interface DownloadAndResizeOpts { } export async function downloadAndResize(opts: DownloadAndResizeOpts) { - let appendExt = 'jpeg' try { - const urip = new URL(opts.uri) - const ext = urip.pathname.split('.').pop() - if (ext === 'png') { - appendExt = 'png' - } + new URL(opts.uri) } catch (e: any) { console.error('Invalid URI', opts.uri, e) return } - const path = createPath(appendExt) + const path = await downloadImage(opts.uri, String(uuid.v4()), opts.timeout) try { - await downloadImage(opts.uri, path, opts.timeout) return await doResize(path, opts) } finally { - safeDeleteAsync(path) + void safeDeleteAsync(path) } } @@ -84,11 +79,13 @@ export async function shareImageModal({uri}: {uri: string}) { return } - // we're currently relying on the fact our CDN only serves jpegs - // -prf - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) - const imagePath = await moveToPermanentPath(imageUri, '.jpg') - safeDeleteAsync(imageUri) + const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { + format: SaveFormat.JPEG, + compress: 1.0, + }) + void safeDeleteAsync(downloadedPath) + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') await Sharing.shareAsync(imagePath, { mimeType: 'image/jpeg', UTI: 'image/jpeg', @@ -98,13 +95,13 @@ export async function shareImageModal({uri}: {uri: string}) { const ALBUM_NAME = 'Bluesky' export async function saveImageToMediaLibrary({uri}: {uri: string}) { - // download the file to cache - // NOTE - // assuming JPEG - // we're currently relying on the fact our CDN only serves jpegs - // -prf - const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) - const imagePath = await moveToPermanentPath(imageUri, '.jpg') + const downloadedPath = await downloadImage(uri, String(uuid.v4()), 15e3) + const {uri: jpegUri} = await manipulateAsync(downloadedPath, [], { + format: SaveFormat.JPEG, + compress: 1.0, + }) + void safeDeleteAsync(downloadedPath) + const imagePath = await moveToPermanentPath(jpegUri, '.jpg') // save try { @@ -402,18 +399,15 @@ export function getResizedDimensions(originalDims: { } } -function createPath(ext: string) { - // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. - // we use a web-only function for downloadAndResize on web - return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` -} - -async function downloadImage(uri: string, path: string, timeout: number) { - const dlResumable = createDownloadResumable(uri, path, {cache: true}) +async function downloadImage(uri: string, destName: string, timeout: number) { + // Download to a temp path first, then rename with the correct extension + // based on the response's mimeType. + const tempPath = `${cacheDirectory ?? ''}/${destName}.bin` + const dlResumable = createDownloadResumable(uri, tempPath, {cache: true}) let timedOut = false const to1 = setTimeout(() => { timedOut = true - dlResumable.cancelAsync() + void dlResumable.cancelAsync() }, timeout) const dlRes = await dlResumable.downloadAsync() @@ -427,5 +421,20 @@ async function downloadImage(uri: string, path: string, timeout: number) { } } - return normalizePath(dlRes.uri) + const ext = extFromMime(dlRes.mimeType) + const finalPath = `${cacheDirectory ?? ''}/${destName}.${ext}` + await moveAsync({from: dlRes.uri, to: finalPath}) + + return normalizePath(finalPath) +} + +const MIME_TO_EXT: Record = { + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/png': 'png', + 'image/gif': 'gif', +} + +function extFromMime(mimeType?: string | null): string { + return (mimeType && MIME_TO_EXT[mimeType]) || 'jpg' } diff --git a/src/screens/Onboarding/StepProfile/index.tsx b/src/screens/Onboarding/StepProfile/index.tsx index a342979ea..8dc1747cc 100644 --- a/src/screens/Onboarding/StepProfile/index.tsx +++ b/src/screens/Onboarding/StepProfile/index.tsx @@ -1,6 +1,7 @@ import React from 'react' import {View} from 'react-native' import {Image as ExpoImage} from 'expo-image' +import {ImageManipulator, SaveFormat} from 'expo-image-manipulator' import { type ImagePickerOptions, launchImageLibraryAsync, @@ -107,27 +108,33 @@ export function StepProfile() { }), ) - return (response.assets ?? []) - .slice(0, 1) - .filter(asset => { - if ( - !asset.mimeType?.startsWith('image/') || - (!asset.mimeType?.endsWith('jpeg') && - !asset.mimeType?.endsWith('jpg') && - !asset.mimeType?.endsWith('png')) - ) { - setError(_(msg`Only .jpg and .png files are supported`)) - return false - } - return true + const asset = (response.assets ?? [])[0] + if (!asset) return [] + + try { + const context = ImageManipulator.manipulate(asset.uri) + const rendered = await context.renderAsync() + const result = await rendered.saveAsync({ + format: SaveFormat.JPEG, + compress: 1.0, }) - .map(image => ({ - mime: 'image/jpeg', - height: image.height, - width: image.width, - path: image.uri, - size: getDataUriSize(image.uri), - })) + return [ + { + mime: 'image/jpeg', + height: rendered.height, + width: rendered.width, + path: result.uri, + size: getDataUriSize(result.uri), + }, + ] + } catch { + setError( + _( + msg`This image could not be used. Try a different format like .jpg or .png.`, + ), + ) + return [] + } }, [_, setError, sheetWrapper], ) -- 2.51.2 From 8b1c47a49983ed88fd1262a5537dbc69c5bc4990 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 2 Mar 2026 22:28:16 +0000 Subject: [PATCH 30/43] bskyweb: use avatar thumbnail for post og:image fallback (#9923) --- bskyweb/cmd/bskyweb/filters.go | 7 +++++++ bskyweb/templates/post.html | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bskyweb/cmd/bskyweb/filters.go b/bskyweb/cmd/bskyweb/filters.go index a92cc606b..4cc5f105f 100644 --- a/bskyweb/cmd/bskyweb/filters.go +++ b/bskyweb/cmd/bskyweb/filters.go @@ -2,12 +2,14 @@ package main import ( "net/url" + "strings" "github.com/flosch/pongo2/v6" ) func init() { pongo2.RegisterFilter("canonicalize_url", filterCanonicalizeURL) + pongo2.RegisterFilter("avatar_thumbnail", filterAvatarThumbnail) } func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { @@ -26,3 +28,8 @@ func filterCanonicalizeURL(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value // Return the cleaned URL return pongo2.AsValue(parsedURL.String()), nil } + +func filterAvatarThumbnail(in *pongo2.Value, param *pongo2.Value) (*pongo2.Value, *pongo2.Error) { + urlStr := in.String() + return pongo2.AsValue(strings.Replace(urlStr, "/img/avatar/plain/", "/img/avatar_thumbnail/plain/", 1)), nil +} diff --git a/bskyweb/templates/post.html b/bskyweb/templates/post.html index 6b6516288..0a94f8c5b 100644 --- a/bskyweb/templates/post.html +++ b/bskyweb/templates/post.html @@ -35,8 +35,8 @@ {% endfor %} {% else %} - - + + {% endif %} -- 2.51.2 From c42502fdcbeb3aa9ea861becb7f4595d5efe04e8 Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Tue, 3 Mar 2026 03:09:48 +0000 Subject: [PATCH 31/43] Nightly source-language update --- src/locale/locales/en/messages.po | 79 ++++++++++++++++--------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 8070c92c9..83ee9b1b0 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -900,7 +900,8 @@ msgstr "" msgid "Add more details (optional)" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:207 +#: src/screens/Settings/LanguageSettings.tsx:201 +#: src/screens/Settings/LanguageSettings.tsx:206 msgid "Add more languages…" msgstr "Add more languages…" @@ -1063,14 +1064,14 @@ msgstr "" msgid "All friends followed!" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:258 +#: src/components/dialogs/LanguageSelectDialog.tsx:263 #: src/screens/Search/components/SearchLanguageDropdown.tsx:65 #: src/screens/Search/components/SearchLanguageDropdown.tsx:100 #: src/screens/Search/components/SearchLanguageDropdown.tsx:102 msgid "All languages" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:173 +#: src/screens/Settings/LanguageSettings.tsx:172 msgid "All languages will be shown in your feeds." msgstr "" @@ -1142,6 +1143,8 @@ msgstr "" msgid "Already signed in as @{0}" msgstr "" +#: src/components/images/AutoSizedImage.tsx:190 +#: src/components/images/Gallery.tsx:120 #: src/components/Post/Embed/VideoEmbed/GifPresentationControls.tsx:94 #: src/view/com/composer/GifAltText.tsx:102 #: src/view/com/composer/photos/Gallery.tsx:189 @@ -1178,7 +1181,7 @@ msgid "An email has been sent to {0}. It includes a confirmation code which you msgstr "" #: src/components/dialogs/GifSelect.tsx:254 -#: src/components/dialogs/LanguageSelectDialog.tsx:348 +#: src/components/dialogs/LanguageSelectDialog.tsx:352 msgid "An error has occurred" msgstr "" @@ -1966,6 +1969,7 @@ msgstr "" msgid "Cashtag {tag}" msgstr "" +#: src/components/Post/Translated/index.tsx:150 #: src/screens/Settings/components/Email2FAToggle.tsx:31 msgid "Change" msgstr "" @@ -2130,7 +2134,7 @@ msgstr "" msgid "Choose for me" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:202 +#: src/components/dialogs/LanguageSelectDialog.tsx:207 msgid "Choose languages" msgstr "" @@ -2235,7 +2239,7 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:233 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:239 #: src/components/dialogs/GifSelect.tsx:270 -#: src/components/dialogs/LanguageSelectDialog.tsx:363 +#: src/components/dialogs/LanguageSelectDialog.tsx:367 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:159 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:168 #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:164 @@ -2287,9 +2291,9 @@ msgstr "" #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:223 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:229 #: src/components/dialogs/GifSelect.tsx:264 -#: src/components/dialogs/LanguageSelectDialog.tsx:224 -#: src/components/dialogs/LanguageSelectDialog.tsx:326 -#: src/components/dialogs/LanguageSelectDialog.tsx:358 +#: src/components/dialogs/LanguageSelectDialog.tsx:229 +#: src/components/dialogs/LanguageSelectDialog.tsx:330 +#: src/components/dialogs/LanguageSelectDialog.tsx:362 #: src/components/verification/VerificationsDialog.tsx:138 #: src/components/verification/VerifierDialog.tsx:139 msgid "Close dialog" @@ -2513,7 +2517,7 @@ msgstr "" msgid "Content from across the network we think you might like." msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:161 +#: src/screens/Settings/LanguageSettings.tsx:160 msgid "Content languages" msgstr "" @@ -2544,7 +2548,7 @@ msgstr "" #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:163 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:171 #: src/screens/Onboarding/StepInterests/index.tsx:93 -#: src/screens/Onboarding/StepProfile/index.tsx:288 +#: src/screens/Onboarding/StepProfile/index.tsx:295 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:302 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:117 msgid "Continue" @@ -2564,7 +2568,7 @@ msgid "Continue thread..." msgstr "" #: src/screens/Onboarding/StepInterests/index.tsx:90 -#: src/screens/Onboarding/StepProfile/index.tsx:285 +#: src/screens/Onboarding/StepProfile/index.tsx:292 #: src/screens/Onboarding/StepSuggestedAccounts/index.tsx:299 #: src/screens/Onboarding/StepSuggestedStarterpacks/index.tsx:114 #: src/screens/Signup/BackNextButtons.tsx:61 @@ -2833,7 +2837,7 @@ msgstr "" msgid "Create an account without using this starter pack" msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:301 +#: src/screens/Onboarding/StepProfile/index.tsx:308 msgid "Create an avatar instead" msgstr "" @@ -3299,15 +3303,15 @@ msgstr "" #: src/components/contacts/screens/ViewMatches.tsx:412 #: src/components/dialogs/BirthDateSettings.tsx:197 #: src/components/dialogs/BirthDateSettings.tsx:204 -#: src/components/dialogs/LanguageSelectDialog.tsx:331 +#: src/components/dialogs/LanguageSelectDialog.tsx:335 #: src/components/dialogs/ServerInput.tsx:241 #: src/components/dialogs/ServerInput.tsx:243 #: src/components/dms/AfterReportDialog.tsx:143 #: src/components/forms/DateField/index.tsx:104 #: src/components/forms/DateField/index.tsx:110 #: src/lib/media/picker.tsx:37 -#: src/screens/Onboarding/StepProfile/index.tsx:338 -#: src/screens/Onboarding/StepProfile/index.tsx:341 +#: src/screens/Onboarding/StepProfile/index.tsx:345 +#: src/screens/Onboarding/StepProfile/index.tsx:348 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:215 #: src/screens/Settings/components/AddAppPasswordDialog.tsx:222 #: src/view/com/composer/labels/LabelsBtn.tsx:219 @@ -3408,7 +3412,6 @@ msgstr "" msgid "Eating disorders" msgstr "" -#: src/components/Post/Translated/index.tsx:150 #: src/screens/Settings/AccountSettings.tsx:146 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:255 #: src/screens/StarterPack/StarterPackScreen.tsx:602 @@ -4654,7 +4657,7 @@ msgstr "" msgid "GIF uploaded" msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:243 +#: src/screens/Onboarding/StepProfile/index.tsx:250 msgid "Give your profile a face" msgstr "" @@ -4875,7 +4878,7 @@ msgstr "" msgid "Help" msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:246 +#: src/screens/Onboarding/StepProfile/index.tsx:253 msgid "Help people know you're not a bot by uploading a picture or creating an avatar." msgstr "" @@ -5108,7 +5111,7 @@ msgstr "" msgid "If alt text is long, toggles alt text expanded state" msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:222 +#: src/screens/Settings/LanguageSettings.tsx:218 msgid "If none are selected, all languages will be shown in your feeds." msgstr "" @@ -5169,7 +5172,7 @@ msgstr "" msgid "If you're trying to change your handle or email, do so before you deactivate." msgstr "" -#: src/components/images/Gallery.tsx:75 +#: src/components/images/Gallery.tsx:76 msgid "Image" msgstr "" @@ -6767,7 +6770,7 @@ msgid "Off" msgstr "" #: src/components/dialogs/GifSelect.tsx:257 -#: src/components/dialogs/LanguageSelectDialog.tsx:351 +#: src/components/dialogs/LanguageSelectDialog.tsx:355 #: src/view/com/util/ErrorBoundary.tsx:57 msgid "Oh no!" msgstr "" @@ -6825,10 +6828,6 @@ msgstr "" msgid "One or more videos is missing alt text." msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:119 -msgid "Only .jpg and .png files are supported" -msgstr "" - #. placeholder {0}: settings.map((rule, i) => ( )) #: src/components/WhoCanReply.tsx:283 msgid "Only {0} can reply." @@ -6861,7 +6860,7 @@ msgstr "" msgid "Oops!" msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:295 +#: src/screens/Onboarding/StepProfile/index.tsx:302 msgid "Open avatar creator" msgstr "" @@ -7902,7 +7901,7 @@ msgstr "" msgid "Recent Searches" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:252 +#: src/components/dialogs/LanguageSelectDialog.tsx:257 msgid "Recently used" msgstr "" @@ -8690,8 +8689,8 @@ msgstr "" msgid "Search is currently unavailable when logged out" msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:235 -#: src/components/dialogs/LanguageSelectDialog.tsx:236 +#: src/components/dialogs/LanguageSelectDialog.tsx:240 +#: src/components/dialogs/LanguageSelectDialog.tsx:241 msgid "Search languages" msgstr "" @@ -8831,8 +8830,8 @@ msgstr "" msgid "Select caption file (.vtt)" msgstr "Select caption file (.vtt)" -#: src/screens/Settings/LanguageSettings.tsx:179 -#: src/screens/Settings/LanguageSettings.tsx:220 +#: src/screens/Settings/LanguageSettings.tsx:178 +#: src/screens/Settings/LanguageSettings.tsx:216 msgid "Select content languages" msgstr "" @@ -8875,7 +8874,7 @@ msgstr "" msgid "Select language..." msgstr "" -#: src/components/dialogs/LanguageSelectDialog.tsx:270 +#: src/components/dialogs/LanguageSelectDialog.tsx:275 msgid "Select languages" msgstr "" @@ -8916,7 +8915,7 @@ msgstr "" msgid "Select which language to use for the app's user interface." msgstr "" -#: src/screens/Settings/LanguageSettings.tsx:165 +#: src/screens/Settings/LanguageSettings.tsx:164 msgid "Select which languages you want your subscribed feeds to include. If none are selected, all languages will be shown." msgstr "" @@ -10113,7 +10112,7 @@ msgid "There was an issue. Please check your internet connection and try again." msgstr "" #: src/components/dialogs/GifSelect.tsx:259 -#: src/components/dialogs/LanguageSelectDialog.tsx:353 +#: src/components/dialogs/LanguageSelectDialog.tsx:357 #: src/view/com/util/ErrorBoundary.tsx:59 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" msgstr "" @@ -10256,6 +10255,10 @@ msgstr "" msgid "This handle is reserved. Please try a different one." msgstr "" +#: src/screens/Onboarding/StepProfile/index.tsx:133 +msgid "This image could not be used. Try a different format like .jpg or .png." +msgstr "This image could not be used. Try a different format like .jpg or .png." + #: src/components/dialogs/BirthDateSettings.tsx:56 msgid "This information is private and not shared with other users." msgstr "" @@ -10880,7 +10883,7 @@ msgctxt "toast" msgid "Updating reply visibility failed" msgstr "" -#: src/screens/Onboarding/StepProfile/index.tsx:299 +#: src/screens/Onboarding/StepProfile/index.tsx:306 msgid "Upload a photo instead" msgstr "" @@ -11336,8 +11339,8 @@ msgstr "" msgid "View your verifications" msgstr "" -#: src/components/images/AutoSizedImage.tsx:206 -#: src/components/images/AutoSizedImage.tsx:233 +#: src/components/images/AutoSizedImage.tsx:207 +#: src/components/images/AutoSizedImage.tsx:234 msgid "Views full image" msgstr "" -- 2.51.2 From 162a78e1ae2a4f60d86867d2e29d4b45a1bd29e5 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 3 Mar 2026 14:06:13 +0000 Subject: [PATCH 32/43] =?UTF-8?q?Add=20=F0=9F=AA=BF=20(#9983)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 3 ++- .../com/composer/text-input/web/EmojiPicker.web.tsx | 2 +- .../composer/text-input/web/EmojiPickerData.json | 1 - .../composer/text-input/web/useWebPreloadEmoji.ts | 2 +- yarn.lock | 13 +++++++++---- 5 files changed, 13 insertions(+), 8 deletions(-) delete mode 100644 src/view/com/composer/text-input/web/EmojiPickerData.json diff --git a/package.json b/package.json index f01c01b7a..3a01a4898 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "@bsky.app/expo-translate-text": "^0.2.4", "@bsky.app/react-native-mmkv": "2.12.5", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", + "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@expo/html-elements": "^0.12.5", "@expo/webpack-config": "^19.0.1", @@ -139,7 +140,7 @@ "bcp-47-match": "^2.0.3", "date-fns": "^2.30.0", "email-validator": "^2.0.4", - "emoji-mart": "^5.5.2", + "emoji-mart": "^5.6.0", "emoji-regex": "^10.4.0", "eventemitter3": "^5.0.1", "expo": "^54.0.27", diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx index 01b4bb9db..fbcd0293b 100644 --- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx +++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx @@ -158,7 +158,7 @@ export function EmojiPicker({state, close, pinToTop}: IProps) { onDismiss={close}> { - return (await import('./EmojiPickerData.json')).default + return (await import('@emoji-mart/data')).default }} onEmojiSelect={onInsert} autoFocus={true} diff --git a/src/view/com/composer/text-input/web/EmojiPickerData.json b/src/view/com/composer/text-input/web/EmojiPickerData.json deleted file mode 100644 index 1b23125d0..000000000 --- a/src/view/com/composer/text-input/web/EmojiPickerData.json +++ /dev/null @@ -1 +0,0 @@ -{"categories":[{"id":"people","emojis":["grinning","smiley","smile","grin","laughing","sweat_smile","rolling_on_the_floor_laughing","joy","slightly_smiling_face","upside_down_face","melting_face","wink","blush","innocent","smiling_face_with_3_hearts","heart_eyes","star-struck","kissing_heart","kissing","relaxed","kissing_closed_eyes","kissing_smiling_eyes","smiling_face_with_tear","yum","stuck_out_tongue","stuck_out_tongue_winking_eye","zany_face","stuck_out_tongue_closed_eyes","money_mouth_face","hugging_face","face_with_hand_over_mouth","face_with_open_eyes_and_hand_over_mouth","face_with_peeking_eye","shushing_face","thinking_face","saluting_face","zipper_mouth_face","face_with_raised_eyebrow","neutral_face","expressionless","no_mouth","dotted_line_face","face_in_clouds","smirk","unamused","face_with_rolling_eyes","grimacing","face_exhaling","lying_face","relieved","pensive","sleepy","drooling_face","sleeping","mask","face_with_thermometer","face_with_head_bandage","nauseated_face","face_vomiting","sneezing_face","hot_face","cold_face","woozy_face","dizzy_face","face_with_spiral_eyes","exploding_head","face_with_cowboy_hat","partying_face","disguised_face","sunglasses","nerd_face","face_with_monocle","confused","face_with_diagonal_mouth","worried","slightly_frowning_face","white_frowning_face","open_mouth","hushed","astonished","flushed","pleading_face","face_holding_back_tears","frowning","anguished","fearful","cold_sweat","disappointed_relieved","cry","sob","scream","confounded","persevere","disappointed","sweat","weary","tired_face","yawning_face","triumph","rage","angry","face_with_symbols_on_mouth","smiling_imp","imp","skull","skull_and_crossbones","hankey","clown_face","japanese_ogre","japanese_goblin","ghost","alien","space_invader","robot_face","wave","raised_back_of_hand","raised_hand_with_fingers_splayed","hand","spock-hand","rightwards_hand","leftwards_hand","palm_down_hand","palm_up_hand","ok_hand","pinched_fingers","pinching_hand","v","crossed_fingers","hand_with_index_finger_and_thumb_crossed","i_love_you_hand_sign","the_horns","call_me_hand","point_left","point_right","point_up_2","middle_finger","point_down","point_up","index_pointing_at_the_viewer","+1","-1","fist","facepunch","left-facing_fist","right-facing_fist","clap","raised_hands","heart_hands","open_hands","palms_up_together","handshake","pray","writing_hand","nail_care","selfie","muscle","mechanical_arm","mechanical_leg","leg","foot","ear","ear_with_hearing_aid","nose","brain","anatomical_heart","lungs","tooth","bone","eyes","eye","tongue","lips","biting_lip","baby","child","boy","girl","adult","person_with_blond_hair","man","bearded_person","man_with_beard","woman_with_beard","red_haired_man","curly_haired_man","white_haired_man","bald_man","woman","red_haired_woman","red_haired_person","curly_haired_woman","curly_haired_person","white_haired_woman","white_haired_person","bald_woman","bald_person","blond-haired-woman","blond-haired-man","older_adult","older_man","older_woman","person_frowning","man-frowning","woman-frowning","person_with_pouting_face","man-pouting","woman-pouting","no_good","man-gesturing-no","woman-gesturing-no","ok_woman","man-gesturing-ok","woman-gesturing-ok","information_desk_person","man-tipping-hand","woman-tipping-hand","raising_hand","man-raising-hand","woman-raising-hand","deaf_person","deaf_man","deaf_woman","bow","man-bowing","woman-bowing","face_palm","man-facepalming","woman-facepalming","shrug","man-shrugging","woman-shrugging","health_worker","male-doctor","female-doctor","student","male-student","female-student","teacher","male-teacher","female-teacher","judge","male-judge","female-judge","farmer","male-farmer","female-farmer","cook","male-cook","female-cook","mechanic","male-mechanic","female-mechanic","factory_worker","male-factory-worker","female-factory-worker","office_worker","male-office-worker","female-office-worker","scientist","male-scientist","female-scientist","technologist","male-technologist","female-technologist","singer","male-singer","female-singer","artist","male-artist","female-artist","pilot","male-pilot","female-pilot","astronaut","male-astronaut","female-astronaut","firefighter","male-firefighter","female-firefighter","cop","male-police-officer","female-police-officer","sleuth_or_spy","male-detective","female-detective","guardsman","male-guard","female-guard","ninja","construction_worker","male-construction-worker","female-construction-worker","person_with_crown","prince","princess","man_with_turban","man-wearing-turban","woman-wearing-turban","man_with_gua_pi_mao","person_with_headscarf","person_in_tuxedo","man_in_tuxedo","woman_in_tuxedo","bride_with_veil","man_with_veil","woman_with_veil","pregnant_woman","pregnant_man","pregnant_person","breast-feeding","woman_feeding_baby","man_feeding_baby","person_feeding_baby","angel","santa","mrs_claus","mx_claus","superhero","male_superhero","female_superhero","supervillain","male_supervillain","female_supervillain","mage","male_mage","female_mage","fairy","male_fairy","female_fairy","vampire","male_vampire","female_vampire","merperson","merman","mermaid","elf","male_elf","female_elf","genie","male_genie","female_genie","zombie","male_zombie","female_zombie","troll","massage","man-getting-massage","woman-getting-massage","haircut","man-getting-haircut","woman-getting-haircut","walking","man-walking","woman-walking","standing_person","man_standing","woman_standing","kneeling_person","man_kneeling","woman_kneeling","person_with_probing_cane","man_with_probing_cane","woman_with_probing_cane","person_in_motorized_wheelchair","man_in_motorized_wheelchair","woman_in_motorized_wheelchair","person_in_manual_wheelchair","man_in_manual_wheelchair","woman_in_manual_wheelchair","runner","man-running","woman-running","dancer","man_dancing","man_in_business_suit_levitating","dancers","men-with-bunny-ears-partying","women-with-bunny-ears-partying","person_in_steamy_room","man_in_steamy_room","woman_in_steamy_room","person_climbing","man_climbing","woman_climbing","fencer","horse_racing","skier","snowboarder","golfer","man-golfing","woman-golfing","surfer","man-surfing","woman-surfing","rowboat","man-rowing-boat","woman-rowing-boat","swimmer","man-swimming","woman-swimming","person_with_ball","man-bouncing-ball","woman-bouncing-ball","weight_lifter","man-lifting-weights","woman-lifting-weights","bicyclist","man-biking","woman-biking","mountain_bicyclist","man-mountain-biking","woman-mountain-biking","person_doing_cartwheel","man-cartwheeling","woman-cartwheeling","wrestlers","man-wrestling","woman-wrestling","water_polo","man-playing-water-polo","woman-playing-water-polo","handball","man-playing-handball","woman-playing-handball","juggling","man-juggling","woman-juggling","person_in_lotus_position","man_in_lotus_position","woman_in_lotus_position","bath","sleeping_accommodation","people_holding_hands","two_women_holding_hands","man_and_woman_holding_hands","two_men_holding_hands","couplekiss","woman-kiss-man","man-kiss-man","woman-kiss-woman","couple_with_heart","woman-heart-man","man-heart-man","woman-heart-woman","family","man-woman-boy","man-woman-girl","man-woman-girl-boy","man-woman-boy-boy","man-woman-girl-girl","man-man-boy","man-man-girl","man-man-girl-boy","man-man-boy-boy","man-man-girl-girl","woman-woman-boy","woman-woman-girl","woman-woman-girl-boy","woman-woman-boy-boy","woman-woman-girl-girl","man-boy","man-boy-boy","man-girl","man-girl-boy","man-girl-girl","woman-boy","woman-boy-boy","woman-girl","woman-girl-boy","woman-girl-girl","speaking_head_in_silhouette","bust_in_silhouette","busts_in_silhouette","people_hugging","footprints","smiley_cat","smile_cat","joy_cat","heart_eyes_cat","smirk_cat","kissing_cat","scream_cat","crying_cat_face","pouting_cat","see_no_evil","hear_no_evil","speak_no_evil","kiss","love_letter","cupid","gift_heart","sparkling_heart","heartpulse","heartbeat","revolving_hearts","two_hearts","heart_decoration","heavy_heart_exclamation_mark_ornament","broken_heart","heart_on_fire","mending_heart","heart","orange_heart","yellow_heart","green_heart","blue_heart","purple_heart","brown_heart","black_heart","white_heart","100","anger","boom","dizzy","sweat_drops","dash","hole","bomb","speech_balloon","eye-in-speech-bubble","left_speech_bubble","right_anger_bubble","thought_balloon","zzz"]},{"id":"nature","emojis":["monkey_face","monkey","gorilla","orangutan","dog","dog2","guide_dog","service_dog","poodle","wolf","fox_face","raccoon","cat","cat2","black_cat","lion_face","tiger","tiger2","leopard","horse","racehorse","unicorn_face","zebra_face","deer","bison","cow","ox","water_buffalo","cow2","pig","pig2","boar","pig_nose","ram","sheep","goat","dromedary_camel","camel","llama","giraffe_face","elephant","mammoth","rhinoceros","hippopotamus","mouse","mouse2","rat","hamster","rabbit","rabbit2","chipmunk","beaver","hedgehog","bat","bear","polar_bear","koala","panda_face","sloth","otter","skunk","kangaroo","badger","feet","turkey","chicken","rooster","hatching_chick","baby_chick","hatched_chick","bird","penguin","dove_of_peace","eagle","duck","swan","owl","dodo","feather","flamingo","peacock","parrot","frog","crocodile","turtle","lizard","snake","dragon_face","dragon","sauropod","t-rex","whale","whale2","dolphin","seal","fish","tropical_fish","blowfish","shark","octopus","shell","coral","snail","butterfly","bug","ant","bee","beetle","ladybug","cricket","cockroach","spider","spider_web","scorpion","mosquito","fly","worm","microbe","bouquet","cherry_blossom","white_flower","lotus","rosette","rose","wilted_flower","hibiscus","sunflower","blossom","tulip","seedling","potted_plant","evergreen_tree","deciduous_tree","palm_tree","cactus","ear_of_rice","herb","shamrock","four_leaf_clover","maple_leaf","fallen_leaf","leaves","empty_nest","nest_with_eggs"]},{"id":"foods","emojis":["grapes","melon","watermelon","tangerine","lemon","banana","pineapple","mango","apple","green_apple","pear","peach","cherries","strawberry","blueberries","kiwifruit","tomato","olive","coconut","avocado","eggplant","potato","carrot","corn","hot_pepper","bell_pepper","cucumber","leafy_green","broccoli","garlic","onion","mushroom","peanuts","beans","chestnut","bread","croissant","baguette_bread","flatbread","pretzel","bagel","pancakes","waffle","cheese_wedge","meat_on_bone","poultry_leg","cut_of_meat","bacon","hamburger","fries","pizza","hotdog","sandwich","taco","burrito","tamale","stuffed_flatbread","falafel","egg","fried_egg","shallow_pan_of_food","stew","fondue","bowl_with_spoon","green_salad","popcorn","butter","salt","canned_food","bento","rice_cracker","rice_ball","rice","curry","ramen","spaghetti","sweet_potato","oden","sushi","fried_shrimp","fish_cake","moon_cake","dango","dumpling","fortune_cookie","takeout_box","crab","lobster","shrimp","squid","oyster","icecream","shaved_ice","ice_cream","doughnut","cookie","birthday","cake","cupcake","pie","chocolate_bar","candy","lollipop","custard","honey_pot","baby_bottle","glass_of_milk","coffee","teapot","tea","sake","champagne","wine_glass","cocktail","tropical_drink","beer","beers","clinking_glasses","tumbler_glass","pouring_liquid","cup_with_straw","bubble_tea","beverage_box","mate_drink","ice_cube","chopsticks","knife_fork_plate","fork_and_knife","spoon","hocho","jar","amphora"]},{"id":"activity","emojis":["jack_o_lantern","christmas_tree","fireworks","sparkler","firecracker","sparkles","balloon","tada","confetti_ball","tanabata_tree","bamboo","dolls","flags","wind_chime","rice_scene","red_envelope","ribbon","gift","reminder_ribbon","admission_tickets","ticket","medal","trophy","sports_medal","first_place_medal","second_place_medal","third_place_medal","soccer","baseball","softball","basketball","volleyball","football","rugby_football","tennis","flying_disc","bowling","cricket_bat_and_ball","field_hockey_stick_and_ball","ice_hockey_stick_and_puck","lacrosse","table_tennis_paddle_and_ball","badminton_racquet_and_shuttlecock","boxing_glove","martial_arts_uniform","goal_net","golf","ice_skate","fishing_pole_and_fish","diving_mask","running_shirt_with_sash","ski","sled","curling_stone","dart","yo-yo","kite","8ball","crystal_ball","magic_wand","nazar_amulet","hamsa","video_game","joystick","slot_machine","game_die","jigsaw","teddy_bear","pinata","mirror_ball","nesting_dolls","spades","hearts","diamonds","clubs","chess_pawn","black_joker","mahjong","flower_playing_cards","performing_arts","frame_with_picture","art","thread","sewing_needle","yarn","knot"]},{"id":"places","emojis":["earth_africa","earth_americas","earth_asia","globe_with_meridians","world_map","japan","compass","snow_capped_mountain","mountain","volcano","mount_fuji","camping","beach_with_umbrella","desert","desert_island","national_park","stadium","classical_building","building_construction","bricks","rock","wood","hut","house_buildings","derelict_house_building","house","house_with_garden","office","post_office","european_post_office","hospital","bank","hotel","love_hotel","convenience_store","school","department_store","factory","japanese_castle","european_castle","wedding","tokyo_tower","statue_of_liberty","church","mosque","hindu_temple","synagogue","shinto_shrine","kaaba","fountain","tent","foggy","night_with_stars","cityscape","sunrise_over_mountains","sunrise","city_sunset","city_sunrise","bridge_at_night","hotsprings","carousel_horse","playground_slide","ferris_wheel","roller_coaster","barber","circus_tent","steam_locomotive","railway_car","bullettrain_side","bullettrain_front","train2","metro","light_rail","station","tram","monorail","mountain_railway","train","bus","oncoming_bus","trolleybus","minibus","ambulance","fire_engine","police_car","oncoming_police_car","taxi","oncoming_taxi","car","oncoming_automobile","blue_car","pickup_truck","truck","articulated_lorry","tractor","racing_car","racing_motorcycle","motor_scooter","manual_wheelchair","motorized_wheelchair","auto_rickshaw","bike","scooter","skateboard","roller_skate","busstop","motorway","railway_track","oil_drum","fuelpump","wheel","rotating_light","traffic_light","vertical_traffic_light","octagonal_sign","construction","anchor","ring_buoy","boat","canoe","speedboat","passenger_ship","ferry","motor_boat","ship","airplane","small_airplane","airplane_departure","airplane_arriving","parachute","seat","helicopter","suspension_railway","mountain_cableway","aerial_tramway","satellite","rocket","flying_saucer","bellhop_bell","luggage","hourglass","hourglass_flowing_sand","watch","alarm_clock","stopwatch","timer_clock","mantelpiece_clock","clock12","clock1230","clock1","clock130","clock2","clock230","clock3","clock330","clock4","clock430","clock5","clock530","clock6","clock630","clock7","clock730","clock8","clock830","clock9","clock930","clock10","clock1030","clock11","clock1130","new_moon","waxing_crescent_moon","first_quarter_moon","moon","full_moon","waning_gibbous_moon","last_quarter_moon","waning_crescent_moon","crescent_moon","new_moon_with_face","first_quarter_moon_with_face","last_quarter_moon_with_face","thermometer","sunny","full_moon_with_face","sun_with_face","ringed_planet","star","star2","stars","milky_way","cloud","partly_sunny","thunder_cloud_and_rain","mostly_sunny","barely_sunny","partly_sunny_rain","rain_cloud","snow_cloud","lightning","tornado","fog","wind_blowing_face","cyclone","rainbow","closed_umbrella","umbrella","umbrella_with_rain_drops","umbrella_on_ground","zap","snowflake","snowman","snowman_without_snow","comet","fire","droplet","ocean"]},{"id":"objects","emojis":["eyeglasses","dark_sunglasses","goggles","lab_coat","safety_vest","necktie","shirt","jeans","scarf","gloves","coat","socks","dress","kimono","sari","one-piece_swimsuit","briefs","shorts","bikini","womans_clothes","purse","handbag","pouch","shopping_bags","school_satchel","thong_sandal","mans_shoe","athletic_shoe","hiking_boot","womans_flat_shoe","high_heel","sandal","ballet_shoes","boot","crown","womans_hat","tophat","mortar_board","billed_cap","military_helmet","helmet_with_white_cross","prayer_beads","lipstick","ring","gem","mute","speaker","sound","loud_sound","loudspeaker","mega","postal_horn","bell","no_bell","musical_score","musical_note","notes","studio_microphone","level_slider","control_knobs","microphone","headphones","radio","saxophone","accordion","guitar","musical_keyboard","trumpet","violin","banjo","drum_with_drumsticks","long_drum","iphone","calling","phone","telephone_receiver","pager","fax","battery","low_battery","electric_plug","computer","desktop_computer","printer","keyboard","three_button_mouse","trackball","minidisc","floppy_disk","cd","dvd","abacus","movie_camera","film_frames","film_projector","clapper","tv","camera","camera_with_flash","video_camera","vhs","mag","mag_right","candle","bulb","flashlight","izakaya_lantern","diya_lamp","notebook_with_decorative_cover","closed_book","book","green_book","blue_book","orange_book","books","notebook","ledger","page_with_curl","scroll","page_facing_up","newspaper","rolled_up_newspaper","bookmark_tabs","bookmark","label","moneybag","coin","yen","dollar","euro","pound","money_with_wings","credit_card","receipt","chart","email","e-mail","incoming_envelope","envelope_with_arrow","outbox_tray","inbox_tray","package","mailbox","mailbox_closed","mailbox_with_mail","mailbox_with_no_mail","postbox","ballot_box_with_ballot","pencil2","black_nib","lower_left_fountain_pen","lower_left_ballpoint_pen","lower_left_paintbrush","lower_left_crayon","memo","briefcase","file_folder","open_file_folder","card_index_dividers","date","calendar","spiral_note_pad","spiral_calendar_pad","card_index","chart_with_upwards_trend","chart_with_downwards_trend","bar_chart","clipboard","pushpin","round_pushpin","paperclip","linked_paperclips","straight_ruler","triangular_ruler","scissors","card_file_box","file_cabinet","wastebasket","lock","unlock","lock_with_ink_pen","closed_lock_with_key","key","old_key","hammer","axe","pick","hammer_and_pick","hammer_and_wrench","dagger_knife","crossed_swords","gun","boomerang","bow_and_arrow","shield","carpentry_saw","wrench","screwdriver","nut_and_bolt","gear","compression","scales","probing_cane","link","chains","hook","toolbox","magnet","ladder","alembic","test_tube","petri_dish","dna","microscope","telescope","satellite_antenna","syringe","drop_of_blood","pill","adhesive_bandage","crutch","stethoscope","x-ray","door","elevator","mirror","window","bed","couch_and_lamp","chair","toilet","plunger","shower","bathtub","mouse_trap","razor","lotion_bottle","safety_pin","broom","basket","roll_of_paper","bucket","soap","bubbles","toothbrush","sponge","fire_extinguisher","shopping_trolley","smoking","coffin","headstone","funeral_urn","moyai","placard","identification_card"]},{"id":"symbols","emojis":["atm","put_litter_in_its_place","potable_water","wheelchair","mens","womens","restroom","baby_symbol","wc","passport_control","customs","baggage_claim","left_luggage","warning","children_crossing","no_entry","no_entry_sign","no_bicycles","no_smoking","do_not_litter","non-potable_water","no_pedestrians","no_mobile_phones","underage","radioactive_sign","biohazard_sign","arrow_up","arrow_upper_right","arrow_right","arrow_lower_right","arrow_down","arrow_lower_left","arrow_left","arrow_upper_left","arrow_up_down","left_right_arrow","leftwards_arrow_with_hook","arrow_right_hook","arrow_heading_up","arrow_heading_down","arrows_clockwise","arrows_counterclockwise","back","end","on","soon","top","place_of_worship","atom_symbol","om_symbol","star_of_david","wheel_of_dharma","yin_yang","latin_cross","orthodox_cross","star_and_crescent","peace_symbol","menorah_with_nine_branches","six_pointed_star","aries","taurus","gemini","cancer","leo","virgo","libra","scorpius","sagittarius","capricorn","aquarius","pisces","ophiuchus","twisted_rightwards_arrows","repeat","repeat_one","arrow_forward","fast_forward","black_right_pointing_double_triangle_with_vertical_bar","black_right_pointing_triangle_with_double_vertical_bar","arrow_backward","rewind","black_left_pointing_double_triangle_with_vertical_bar","arrow_up_small","arrow_double_up","arrow_down_small","arrow_double_down","double_vertical_bar","black_square_for_stop","black_circle_for_record","eject","cinema","low_brightness","high_brightness","signal_strength","vibration_mode","mobile_phone_off","female_sign","male_sign","transgender_symbol","heavy_multiplication_x","heavy_plus_sign","heavy_minus_sign","heavy_division_sign","heavy_equals_sign","infinity","bangbang","interrobang","question","grey_question","grey_exclamation","exclamation","wavy_dash","currency_exchange","heavy_dollar_sign","medical_symbol","recycle","fleur_de_lis","trident","name_badge","beginner","o","white_check_mark","ballot_box_with_check","heavy_check_mark","x","negative_squared_cross_mark","curly_loop","loop","part_alternation_mark","eight_spoked_asterisk","eight_pointed_black_star","sparkle","copyright","registered","tm","hash","keycap_star","zero","one","two","three","four","five","six","seven","eight","nine","keycap_ten","capital_abcd","abcd","1234","symbols","abc","a","ab","b","cl","cool","free","information_source","id","m","new","ng","o2","ok","parking","sos","up","vs","koko","sa","u6708","u6709","u6307","ideograph_advantage","u5272","u7121","u7981","accept","u7533","u5408","u7a7a","congratulations","secret","u55b6","u6e80","red_circle","large_orange_circle","large_yellow_circle","large_green_circle","large_blue_circle","large_purple_circle","large_brown_circle","black_circle","white_circle","large_red_square","large_orange_square","large_yellow_square","large_green_square","large_blue_square","large_purple_square","large_brown_square","black_large_square","white_large_square","black_medium_square","white_medium_square","black_medium_small_square","white_medium_small_square","black_small_square","white_small_square","large_orange_diamond","large_blue_diamond","small_orange_diamond","small_blue_diamond","small_red_triangle","small_red_triangle_down","diamond_shape_with_a_dot_inside","radio_button","white_square_button","black_square_button"]},{"id":"flags","emojis":["checkered_flag","cn","crossed_flags","de","es","flag-ac","flag-ad","flag-ae","flag-af","flag-ag","flag-ai","flag-al","flag-am","flag-ao","flag-aq","flag-ar","flag-as","flag-at","flag-au","flag-aw","flag-ax","flag-az","flag-ba","flag-bb","flag-bd","flag-be","flag-bf","flag-bg","flag-bh","flag-bi","flag-bj","flag-bl","flag-bm","flag-bn","flag-bo","flag-bq","flag-br","flag-bs","flag-bt","flag-bv","flag-bw","flag-by","flag-bz","flag-ca","flag-cc","flag-cd","flag-cf","flag-cg","flag-ch","flag-ci","flag-ck","flag-cl","flag-cm","flag-co","flag-cp","flag-cr","flag-cu","flag-cv","flag-cw","flag-cx","flag-cy","flag-cz","flag-dg","flag-dj","flag-dk","flag-dm","flag-do","flag-dz","flag-ea","flag-ec","flag-ee","flag-eg","flag-eh","flag-england","flag-er","flag-et","flag-eu","flag-fi","flag-fj","flag-fk","flag-fm","flag-fo","flag-ga","flag-gd","flag-ge","flag-gf","flag-gg","flag-gh","flag-gi","flag-gl","flag-gm","flag-gn","flag-gp","flag-gq","flag-gr","flag-gs","flag-gt","flag-gu","flag-gw","flag-gy","flag-hk","flag-hm","flag-hn","flag-hr","flag-ht","flag-hu","flag-ic","flag-id","flag-ie","flag-il","flag-im","flag-in","flag-io","flag-iq","flag-ir","flag-is","flag-je","flag-jm","flag-jo","flag-ke","flag-kg","flag-kh","flag-ki","flag-km","flag-kn","flag-kp","flag-kw","flag-ky","flag-kz","flag-la","flag-lb","flag-lc","flag-li","flag-lk","flag-lr","flag-ls","flag-lt","flag-lu","flag-lv","flag-ly","flag-ma","flag-mc","flag-md","flag-me","flag-mf","flag-mg","flag-mh","flag-mk","flag-ml","flag-mm","flag-mn","flag-mo","flag-mp","flag-mq","flag-mr","flag-ms","flag-mt","flag-mu","flag-mv","flag-mw","flag-mx","flag-my","flag-mz","flag-na","flag-nc","flag-ne","flag-nf","flag-ng","flag-ni","flag-nl","flag-no","flag-np","flag-nr","flag-nu","flag-nz","flag-om","flag-pa","flag-pe","flag-pf","flag-pg","flag-ph","flag-pk","flag-pl","flag-pm","flag-pn","flag-pr","flag-ps","flag-pt","flag-pw","flag-py","flag-qa","flag-re","flag-ro","flag-rs","flag-rw","flag-sa","flag-sb","flag-sc","flag-scotland","flag-sd","flag-se","flag-sg","flag-sh","flag-si","flag-sj","flag-sk","flag-sl","flag-sm","flag-sn","flag-so","flag-sr","flag-ss","flag-st","flag-sv","flag-sx","flag-sy","flag-sz","flag-ta","flag-tc","flag-td","flag-tf","flag-tg","flag-th","flag-tj","flag-tk","flag-tl","flag-tm","flag-tn","flag-to","flag-tr","flag-tt","flag-tv","flag-tw","flag-tz","flag-ua","flag-ug","flag-um","flag-un","flag-uy","flag-uz","flag-va","flag-vc","flag-ve","flag-vg","flag-vi","flag-vn","flag-vu","flag-wales","flag-wf","flag-ws","flag-xk","flag-ye","flag-yt","flag-za","flag-zm","flag-zw","fr","gb","it","jp","kr","pirate_flag","rainbow-flag","ru","transgender_flag","triangular_flag_on_post","us","waving_black_flag","waving_white_flag"]}],"emojis":{"100":{"id":"100","name":"Hundred Points","keywords":["100","score","perfect","numbers","century","exam","quiz","test","pass"],"skins":[{"unified":"1f4af","native":"💯"}],"version":1},"1234":{"id":"1234","name":"Input Numbers","keywords":["1234","blue","square"],"skins":[{"unified":"1f522","native":"🔢"}],"version":1},"grinning":{"id":"grinning","name":"Grinning Face","emoticons":[":D"],"keywords":["smile","happy","joy",":D","grin"],"skins":[{"unified":"1f600","native":"😀"}],"version":1},"smiley":{"id":"smiley","name":"Grinning Face with Big Eyes","emoticons":[":)","=)","=-)"],"keywords":["smiley","happy","joy","haha",":D",":)","smile","funny"],"skins":[{"unified":"1f603","native":"😃"}],"version":1},"smile":{"id":"smile","name":"Grinning Face with Smiling Eyes","emoticons":[":)","C:","c:",":D",":-D"],"keywords":["smile","happy","joy","funny","haha","laugh","like",":D",":)"],"skins":[{"unified":"1f604","native":"😄"}],"version":1},"grin":{"id":"grin","name":"Beaming Face with Smiling Eyes","keywords":["grin","happy","smile","joy","kawaii"],"skins":[{"unified":"1f601","native":"😁"}],"version":1},"laughing":{"id":"laughing","name":"Grinning Squinting Face","emoticons":[":>",":->"],"keywords":["laughing","satisfied","happy","joy","lol","haha","glad","XD","laugh"],"skins":[{"unified":"1f606","native":"😆"}],"version":1},"sweat_smile":{"id":"sweat_smile","name":"Grinning Face with Sweat","keywords":["smile","hot","happy","laugh","relief"],"skins":[{"unified":"1f605","native":"😅"}],"version":1},"rolling_on_the_floor_laughing":{"id":"rolling_on_the_floor_laughing","name":"Rolling on the Floor Laughing","keywords":["face","lol","haha","rofl"],"skins":[{"unified":"1f923","native":"🤣"}],"version":3},"joy":{"id":"joy","name":"Face with Tears of Joy","keywords":["cry","weep","happy","happytears","haha"],"skins":[{"unified":"1f602","native":"😂"}],"version":1},"slightly_smiling_face":{"id":"slightly_smiling_face","name":"Slightly Smiling Face","emoticons":[":)","(:",":-)"],"keywords":["smile"],"skins":[{"unified":"1f642","native":"🙂"}],"version":1},"upside_down_face":{"id":"upside_down_face","name":"Upside-Down Face","keywords":["upside","down","flipped","silly","smile"],"skins":[{"unified":"1f643","native":"🙃"}],"version":1},"melting_face":{"id":"melting_face","name":"Melting Face","keywords":["hot","heat"],"skins":[{"unified":"1fae0","native":"🫠"}],"version":14},"wink":{"id":"wink","name":"Winking Face","emoticons":[";)",";-)"],"keywords":["wink","happy","mischievous","secret",";)","smile","eye"],"skins":[{"unified":"1f609","native":"😉"}],"version":1},"blush":{"id":"blush","name":"Smiling Face with Smiling Eyes","emoticons":[":)"],"keywords":["blush","smile","happy","flushed","crush","embarrassed","shy","joy"],"skins":[{"unified":"1f60a","native":"😊"}],"version":1},"innocent":{"id":"innocent","name":"Smiling Face with Halo","keywords":["innocent","angel","heaven"],"skins":[{"unified":"1f607","native":"😇"}],"version":1},"smiling_face_with_3_hearts":{"id":"smiling_face_with_3_hearts","name":"Smiling Face with Hearts","keywords":["3","love","like","affection","valentines","infatuation","crush","adore"],"skins":[{"unified":"1f970","native":"🥰"}],"version":11},"heart_eyes":{"id":"heart_eyes","name":"Smiling Face with Heart-Eyes","keywords":["heart","eyes","love","like","affection","valentines","infatuation","crush"],"skins":[{"unified":"1f60d","native":"😍"}],"version":1},"star-struck":{"id":"star-struck","name":"Star-Struck","keywords":["star","struck","grinning","face","with","eyes","smile","starry"],"skins":[{"unified":"1f929","native":"🤩"}],"version":5},"kissing_heart":{"id":"kissing_heart","name":"Face Blowing a Kiss","emoticons":[":*",":-*"],"keywords":["kissing","heart","love","like","affection","valentines","infatuation"],"skins":[{"unified":"1f618","native":"😘"}],"version":1},"kissing":{"id":"kissing","name":"Kissing Face","keywords":["love","like","3","valentines","infatuation","kiss"],"skins":[{"unified":"1f617","native":"😗"}],"version":1},"relaxed":{"id":"relaxed","name":"Smiling Face","keywords":["relaxed","blush","massage","happiness"],"skins":[{"unified":"263a-fe0f","native":"☺️"}],"version":1},"kissing_closed_eyes":{"id":"kissing_closed_eyes","name":"Kissing Face with Closed Eyes","keywords":["love","like","affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f61a","native":"😚"}],"version":1},"kissing_smiling_eyes":{"id":"kissing_smiling_eyes","name":"Kissing Face with Smiling Eyes","keywords":["affection","valentines","infatuation","kiss"],"skins":[{"unified":"1f619","native":"😙"}],"version":1},"smiling_face_with_tear":{"id":"smiling_face_with_tear","name":"Smiling Face with Tear","keywords":["sad","cry","pretend"],"skins":[{"unified":"1f972","native":"🥲"}],"version":13},"yum":{"id":"yum","name":"Face Savoring Food","keywords":["yum","happy","joy","tongue","smile","silly","yummy","nom","delicious","savouring"],"skins":[{"unified":"1f60b","native":"😋"}],"version":1},"stuck_out_tongue":{"id":"stuck_out_tongue","name":"Face with Tongue","emoticons":[":p",":-p",":P",":-P",":b",":-b"],"keywords":["stuck","out","prank","childish","playful","mischievous","smile"],"skins":[{"unified":"1f61b","native":"😛"}],"version":1},"stuck_out_tongue_winking_eye":{"id":"stuck_out_tongue_winking_eye","name":"Winking Face with Tongue","emoticons":[";p",";-p",";b",";-b",";P",";-P"],"keywords":["stuck","out","eye","prank","childish","playful","mischievous","smile","wink"],"skins":[{"unified":"1f61c","native":"😜"}],"version":1},"zany_face":{"id":"zany_face","name":"Zany Face","keywords":["grinning","with","one","large","and","small","eye","goofy","crazy"],"skins":[{"unified":"1f92a","native":"🤪"}],"version":5},"stuck_out_tongue_closed_eyes":{"id":"stuck_out_tongue_closed_eyes","name":"Squinting Face with Tongue","keywords":["stuck","out","closed","eyes","prank","playful","mischievous","smile"],"skins":[{"unified":"1f61d","native":"😝"}],"version":1},"money_mouth_face":{"id":"money_mouth_face","name":"Money-Mouth Face","keywords":["money","mouth","rich","dollar"],"skins":[{"unified":"1f911","native":"🤑"}],"version":1},"hugging_face":{"id":"hugging_face","name":"Hugging Face","keywords":["smile","hug"],"skins":[{"unified":"1f917","native":"🤗"}],"version":1},"face_with_hand_over_mouth":{"id":"face_with_hand_over_mouth","name":"Face with Hand over Mouth","keywords":["smiling","eyes","and","covering","whoops","shock","surprise"],"skins":[{"unified":"1f92d","native":"🤭"}],"version":5},"face_with_open_eyes_and_hand_over_mouth":{"id":"face_with_open_eyes_and_hand_over_mouth","name":"Face with Open Eyes and Hand over Mouth","keywords":["silence","secret","shock","surprise"],"skins":[{"unified":"1fae2","native":"🫢"}],"version":14},"face_with_peeking_eye":{"id":"face_with_peeking_eye","name":"Face with Peeking Eye","keywords":["scared","frightening","embarrassing"],"skins":[{"unified":"1fae3","native":"🫣"}],"version":14},"shushing_face":{"id":"shushing_face","name":"Shushing Face","keywords":["with","finger","covering","closed","lips","quiet","shhh"],"skins":[{"unified":"1f92b","native":"🤫"}],"version":5},"thinking_face":{"id":"thinking_face","name":"Thinking Face","keywords":["hmmm","think","consider"],"skins":[{"unified":"1f914","native":"🤔"}],"version":1},"saluting_face":{"id":"saluting_face","name":"Saluting Face","keywords":["respect","salute"],"skins":[{"unified":"1fae1","native":"🫡"}],"version":14},"zipper_mouth_face":{"id":"zipper_mouth_face","name":"Zipper-Mouth Face","keywords":["zipper","mouth","sealed","secret"],"skins":[{"unified":"1f910","native":"🤐"}],"version":1},"face_with_raised_eyebrow":{"id":"face_with_raised_eyebrow","name":"Face with Raised Eyebrow","keywords":["one","distrust","scepticism","disapproval","disbelief","surprise"],"skins":[{"unified":"1f928","native":"🤨"}],"version":5},"neutral_face":{"id":"neutral_face","name":"Neutral Face","emoticons":[":|",":-|"],"keywords":["indifference","meh",":",""],"skins":[{"unified":"1f610","native":"😐"}],"version":1},"expressionless":{"id":"expressionless","name":"Expressionless Face","emoticons":["-_-"],"keywords":["indifferent","-","","meh","deadpan"],"skins":[{"unified":"1f611","native":"😑"}],"version":1},"no_mouth":{"id":"no_mouth","name":"Face Without Mouth","keywords":["no","hellokitty"],"skins":[{"unified":"1f636","native":"😶"}],"version":1},"dotted_line_face":{"id":"dotted_line_face","name":"Dotted Line Face","keywords":["invisible","lonely","isolation","depression"],"skins":[{"unified":"1fae5","native":"🫥"}],"version":14},"face_in_clouds":{"id":"face_in_clouds","name":"Face in Clouds","keywords":["shower","steam","dream"],"skins":[{"unified":"1f636-200d-1f32b-fe0f","native":"😶‍🌫️"}],"version":13.1},"smirk":{"id":"smirk","name":"Smirking Face","keywords":["smirk","smile","mean","prank","smug","sarcasm"],"skins":[{"unified":"1f60f","native":"😏"}],"version":1},"unamused":{"id":"unamused","name":"Unamused Face","emoticons":[":("],"keywords":["indifference","bored","straight","serious","sarcasm","unimpressed","skeptical","dubious","side","eye"],"skins":[{"unified":"1f612","native":"😒"}],"version":1},"face_with_rolling_eyes":{"id":"face_with_rolling_eyes","name":"Face with Rolling Eyes","keywords":["eyeroll","frustrated"],"skins":[{"unified":"1f644","native":"🙄"}],"version":1},"grimacing":{"id":"grimacing","name":"Grimacing Face","keywords":["grimace","teeth"],"skins":[{"unified":"1f62c","native":"😬"}],"version":1},"face_exhaling":{"id":"face_exhaling","name":"Face Exhaling","keywords":["relieve","relief","tired","sigh"],"skins":[{"unified":"1f62e-200d-1f4a8","native":"😮‍💨"}],"version":13.1},"lying_face":{"id":"lying_face","name":"Lying Face","keywords":["lie","pinocchio"],"skins":[{"unified":"1f925","native":"🤥"}],"version":3},"relieved":{"id":"relieved","name":"Relieved Face","keywords":["relaxed","phew","massage","happiness"],"skins":[{"unified":"1f60c","native":"😌"}],"version":1},"pensive":{"id":"pensive","name":"Pensive Face","keywords":["sad","depressed","upset"],"skins":[{"unified":"1f614","native":"😔"}],"version":1},"sleepy":{"id":"sleepy","name":"Sleepy Face","keywords":["tired","rest","nap"],"skins":[{"unified":"1f62a","native":"😪"}],"version":1},"drooling_face":{"id":"drooling_face","name":"Drooling Face","keywords":[],"skins":[{"unified":"1f924","native":"🤤"}],"version":3},"sleeping":{"id":"sleeping","name":"Sleeping Face","keywords":["tired","sleepy","night","zzz"],"skins":[{"unified":"1f634","native":"😴"}],"version":1},"mask":{"id":"mask","name":"Face with Medical Mask","keywords":["sick","ill","disease"],"skins":[{"unified":"1f637","native":"😷"}],"version":1},"face_with_thermometer":{"id":"face_with_thermometer","name":"Face with Thermometer","keywords":["sick","temperature","cold","fever"],"skins":[{"unified":"1f912","native":"🤒"}],"version":1},"face_with_head_bandage":{"id":"face_with_head_bandage","name":"Face with Head-Bandage","keywords":["head","bandage","injured","clumsy","hurt"],"skins":[{"unified":"1f915","native":"🤕"}],"version":1},"nauseated_face":{"id":"nauseated_face","name":"Nauseated Face","keywords":["vomit","gross","green","sick","throw","up","ill"],"skins":[{"unified":"1f922","native":"🤢"}],"version":3},"face_vomiting":{"id":"face_vomiting","name":"Face Vomiting","keywords":["with","open","mouth","sick"],"skins":[{"unified":"1f92e","native":"🤮"}],"version":5},"sneezing_face":{"id":"sneezing_face","name":"Sneezing Face","keywords":["gesundheit","sneeze","sick","allergy"],"skins":[{"unified":"1f927","native":"🤧"}],"version":3},"hot_face":{"id":"hot_face","name":"Hot Face","keywords":["feverish","heat","red","sweating"],"skins":[{"unified":"1f975","native":"🥵"}],"version":11},"cold_face":{"id":"cold_face","name":"Cold Face","keywords":["blue","freezing","frozen","frostbite","icicles"],"skins":[{"unified":"1f976","native":"🥶"}],"version":11},"woozy_face":{"id":"woozy_face","name":"Woozy Face","keywords":["dizzy","intoxicated","tipsy","wavy"],"skins":[{"unified":"1f974","native":"🥴"}],"version":11},"dizzy_face":{"id":"dizzy_face","name":"Dizzy Face","keywords":["spent","unconscious","xox"],"skins":[{"unified":"1f635","native":"😵"}],"version":1},"face_with_spiral_eyes":{"id":"face_with_spiral_eyes","name":"Face with Spiral Eyes","keywords":["sick","ill","confused","nauseous","nausea"],"skins":[{"unified":"1f635-200d-1f4ab","native":"😵‍💫"}],"version":13.1},"exploding_head":{"id":"exploding_head","name":"Exploding Head","keywords":["shocked","face","with","mind","blown"],"skins":[{"unified":"1f92f","native":"🤯"}],"version":5},"face_with_cowboy_hat":{"id":"face_with_cowboy_hat","name":"Cowboy Hat Face","keywords":["with","cowgirl"],"skins":[{"unified":"1f920","native":"🤠"}],"version":3},"partying_face":{"id":"partying_face","name":"Partying Face","keywords":["celebration","woohoo"],"skins":[{"unified":"1f973","native":"🥳"}],"version":11},"disguised_face":{"id":"disguised_face","name":"Disguised Face","keywords":["pretent","brows","glasses","moustache"],"skins":[{"unified":"1f978","native":"🥸"}],"version":13},"sunglasses":{"id":"sunglasses","name":"Smiling Face with Sunglasses","emoticons":["8)"],"keywords":["cool","smile","summer","beach","sunglass"],"skins":[{"unified":"1f60e","native":"😎"}],"version":1},"nerd_face":{"id":"nerd_face","name":"Nerd Face","keywords":["nerdy","geek","dork"],"skins":[{"unified":"1f913","native":"🤓"}],"version":1},"face_with_monocle":{"id":"face_with_monocle","name":"Face with Monocle","keywords":["stuffy","wealthy"],"skins":[{"unified":"1f9d0","native":"🧐"}],"version":5},"confused":{"id":"confused","name":"Confused Face","emoticons":[":\\",":-\\",":/",":-/"],"keywords":["indifference","huh","weird","hmmm",":/"],"skins":[{"unified":"1f615","native":"😕"}],"version":1},"face_with_diagonal_mouth":{"id":"face_with_diagonal_mouth","name":"Face with Diagonal Mouth","keywords":["skeptic","confuse","frustrated","indifferent"],"skins":[{"unified":"1fae4","native":"🫤"}],"version":14},"worried":{"id":"worried","name":"Worried Face","keywords":["concern","nervous",":("],"skins":[{"unified":"1f61f","native":"😟"}],"version":1},"slightly_frowning_face":{"id":"slightly_frowning_face","name":"Slightly Frowning Face","keywords":["disappointed","sad","upset"],"skins":[{"unified":"1f641","native":"🙁"}],"version":1},"white_frowning_face":{"id":"white_frowning_face","name":"Frowning Face","keywords":["white","sad","upset","frown"],"skins":[{"unified":"2639-fe0f","native":"☹️"}],"version":1},"open_mouth":{"id":"open_mouth","name":"Face with Open Mouth","emoticons":[":o",":-o",":O",":-O"],"keywords":["surprise","impressed","wow","whoa",":O"],"skins":[{"unified":"1f62e","native":"😮"}],"version":1},"hushed":{"id":"hushed","name":"Hushed Face","keywords":["woo","shh"],"skins":[{"unified":"1f62f","native":"😯"}],"version":1},"astonished":{"id":"astonished","name":"Astonished Face","keywords":["xox","surprised","poisoned"],"skins":[{"unified":"1f632","native":"😲"}],"version":1},"flushed":{"id":"flushed","name":"Flushed Face","keywords":["blush","shy","flattered"],"skins":[{"unified":"1f633","native":"😳"}],"version":1},"pleading_face":{"id":"pleading_face","name":"Pleading Face","keywords":["begging","mercy"],"skins":[{"unified":"1f97a","native":"🥺"}],"version":11},"face_holding_back_tears":{"id":"face_holding_back_tears","name":"Face Holding Back Tears","keywords":["touched","gratitude"],"skins":[{"unified":"1f979","native":"🥹"}],"version":14},"frowning":{"id":"frowning","name":"Frowning Face with Open Mouth","keywords":["aw","what"],"skins":[{"unified":"1f626","native":"😦"}],"version":1},"anguished":{"id":"anguished","name":"Anguished Face","emoticons":["D:"],"keywords":["stunned","nervous"],"skins":[{"unified":"1f627","native":"😧"}],"version":1},"fearful":{"id":"fearful","name":"Fearful Face","keywords":["scared","terrified","nervous","oops","huh"],"skins":[{"unified":"1f628","native":"😨"}],"version":1},"cold_sweat":{"id":"cold_sweat","name":"Anxious Face with Sweat","keywords":["cold","nervous"],"skins":[{"unified":"1f630","native":"😰"}],"version":1},"disappointed_relieved":{"id":"disappointed_relieved","name":"Sad but Relieved Face","keywords":["disappointed","phew","sweat","nervous"],"skins":[{"unified":"1f625","native":"😥"}],"version":1},"cry":{"id":"cry","name":"Crying Face","emoticons":[":'("],"keywords":["cry","tears","sad","depressed","upset",":'("],"skins":[{"unified":"1f622","native":"😢"}],"version":1},"sob":{"id":"sob","name":"Loudly Crying Face","emoticons":[":'("],"keywords":["sob","cry","tears","sad","upset","depressed"],"skins":[{"unified":"1f62d","native":"😭"}],"version":1},"scream":{"id":"scream","name":"Face Screaming in Fear","keywords":["scream","munch","scared","omg"],"skins":[{"unified":"1f631","native":"😱"}],"version":1},"confounded":{"id":"confounded","name":"Confounded Face","keywords":["confused","sick","unwell","oops",":S"],"skins":[{"unified":"1f616","native":"😖"}],"version":1},"persevere":{"id":"persevere","name":"Persevering Face","keywords":["persevere","sick","no","upset","oops"],"skins":[{"unified":"1f623","native":"😣"}],"version":1},"disappointed":{"id":"disappointed","name":"Disappointed Face","emoticons":["):",":(",":-("],"keywords":["sad","upset","depressed",":("],"skins":[{"unified":"1f61e","native":"😞"}],"version":1},"sweat":{"id":"sweat","name":"Face with Cold Sweat","keywords":["downcast","hot","sad","tired","exercise"],"skins":[{"unified":"1f613","native":"😓"}],"version":1},"weary":{"id":"weary","name":"Weary Face","keywords":["tired","sleepy","sad","frustrated","upset"],"skins":[{"unified":"1f629","native":"😩"}],"version":1},"tired_face":{"id":"tired_face","name":"Tired Face","keywords":["sick","whine","upset","frustrated"],"skins":[{"unified":"1f62b","native":"😫"}],"version":1},"yawning_face":{"id":"yawning_face","name":"Yawning Face","keywords":["tired","sleepy"],"skins":[{"unified":"1f971","native":"🥱"}],"version":12},"triumph":{"id":"triumph","name":"Face with Look of Triumph","keywords":["steam","from","nose","gas","phew","proud","pride"],"skins":[{"unified":"1f624","native":"😤"}],"version":1},"rage":{"id":"rage","name":"Pouting Face","keywords":["rage","angry","mad","hate","despise"],"skins":[{"unified":"1f621","native":"😡"}],"version":1},"angry":{"id":"angry","name":"Angry Face","emoticons":[">:(",">:-("],"keywords":["mad","annoyed","frustrated"],"skins":[{"unified":"1f620","native":"😠"}],"version":1},"face_with_symbols_on_mouth":{"id":"face_with_symbols_on_mouth","name":"Face with Symbols on Mouth","keywords":["serious","covering","swearing","cursing","cussing","profanity","expletive"],"skins":[{"unified":"1f92c","native":"🤬"}],"version":5},"smiling_imp":{"id":"smiling_imp","name":"Smiling Face with Horns","keywords":["imp","devil"],"skins":[{"unified":"1f608","native":"😈"}],"version":1},"imp":{"id":"imp","name":"Imp","keywords":["angry","face","with","horns","devil"],"skins":[{"unified":"1f47f","native":"👿"}],"version":1},"skull":{"id":"skull","name":"Skull","keywords":["dead","skeleton","creepy","death"],"skins":[{"unified":"1f480","native":"💀"}],"version":1},"skull_and_crossbones":{"id":"skull_and_crossbones","name":"Skull and Crossbones","keywords":["poison","danger","deadly","scary","death","pirate","evil"],"skins":[{"unified":"2620-fe0f","native":"☠️"}],"version":1},"hankey":{"id":"hankey","name":"Pile of Poo","keywords":["hankey","poop","shit","shitface","fail","turd"],"skins":[{"unified":"1f4a9","native":"💩"}],"version":1},"clown_face":{"id":"clown_face","name":"Clown Face","keywords":[],"skins":[{"unified":"1f921","native":"🤡"}],"version":3},"japanese_ogre":{"id":"japanese_ogre","name":"Ogre","keywords":["japanese","monster","red","mask","halloween","scary","creepy","devil","demon"],"skins":[{"unified":"1f479","native":"👹"}],"version":1},"japanese_goblin":{"id":"japanese_goblin","name":"Goblin","keywords":["japanese","red","evil","mask","monster","scary","creepy"],"skins":[{"unified":"1f47a","native":"👺"}],"version":1},"ghost":{"id":"ghost","name":"Ghost","keywords":["halloween","spooky","scary"],"skins":[{"unified":"1f47b","native":"👻"}],"version":1},"alien":{"id":"alien","name":"Alien","keywords":["UFO","paul","weird","outer","space"],"skins":[{"unified":"1f47d","native":"👽"}],"version":1},"space_invader":{"id":"space_invader","name":"Alien Monster","keywords":["space","invader","game","arcade","play"],"skins":[{"unified":"1f47e","native":"👾"}],"version":1},"robot_face":{"id":"robot_face","name":"Robot","keywords":["face","computer","machine","bot"],"skins":[{"unified":"1f916","native":"🤖"}],"version":1},"smiley_cat":{"id":"smiley_cat","name":"Grinning Cat","keywords":["smiley","animal","cats","happy","smile"],"skins":[{"unified":"1f63a","native":"😺"}],"version":1},"smile_cat":{"id":"smile_cat","name":"Grinning Cat with Smiling Eyes","keywords":["smile","animal","cats"],"skins":[{"unified":"1f638","native":"😸"}],"version":1},"joy_cat":{"id":"joy_cat","name":"Cat with Tears of Joy","keywords":["animal","cats","haha","happy"],"skins":[{"unified":"1f639","native":"😹"}],"version":1},"heart_eyes_cat":{"id":"heart_eyes_cat","name":"Smiling Cat with Heart-Eyes","keywords":["heart","eyes","animal","love","like","affection","cats","valentines"],"skins":[{"unified":"1f63b","native":"😻"}],"version":1},"smirk_cat":{"id":"smirk_cat","name":"Cat with Wry Smile","keywords":["smirk","animal","cats"],"skins":[{"unified":"1f63c","native":"😼"}],"version":1},"kissing_cat":{"id":"kissing_cat","name":"Kissing Cat","keywords":["animal","cats","kiss"],"skins":[{"unified":"1f63d","native":"😽"}],"version":1},"scream_cat":{"id":"scream_cat","name":"Weary Cat","keywords":["scream","animal","cats","munch","scared"],"skins":[{"unified":"1f640","native":"🙀"}],"version":1},"crying_cat_face":{"id":"crying_cat_face","name":"Crying Cat","keywords":["face","animal","tears","weep","sad","cats","upset","cry"],"skins":[{"unified":"1f63f","native":"😿"}],"version":1},"pouting_cat":{"id":"pouting_cat","name":"Pouting Cat","keywords":["animal","cats"],"skins":[{"unified":"1f63e","native":"😾"}],"version":1},"see_no_evil":{"id":"see_no_evil","name":"See-No-Evil Monkey","keywords":["see","no","evil","animal","nature","haha"],"skins":[{"unified":"1f648","native":"🙈"}],"version":1},"hear_no_evil":{"id":"hear_no_evil","name":"Hear-No-Evil Monkey","keywords":["hear","no","evil","animal","nature"],"skins":[{"unified":"1f649","native":"🙉"}],"version":1},"speak_no_evil":{"id":"speak_no_evil","name":"Speak-No-Evil Monkey","keywords":["speak","no","evil","animal","nature","omg"],"skins":[{"unified":"1f64a","native":"🙊"}],"version":1},"kiss":{"id":"kiss","name":"Kiss Mark","keywords":["face","lips","love","like","affection","valentines"],"skins":[{"unified":"1f48b","native":"💋"}],"version":1},"love_letter":{"id":"love_letter","name":"Love Letter","keywords":["email","like","affection","envelope","valentines"],"skins":[{"unified":"1f48c","native":"💌"}],"version":1},"cupid":{"id":"cupid","name":"Heart with Arrow","keywords":["cupid","love","like","affection","valentines"],"skins":[{"unified":"1f498","native":"💘"}],"version":1},"gift_heart":{"id":"gift_heart","name":"Heart with Ribbon","keywords":["gift","love","valentines"],"skins":[{"unified":"1f49d","native":"💝"}],"version":1},"sparkling_heart":{"id":"sparkling_heart","name":"Sparkling Heart","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f496","native":"💖"}],"version":1},"heartpulse":{"id":"heartpulse","name":"Growing Heart","keywords":["heartpulse","like","love","affection","valentines","pink"],"skins":[{"unified":"1f497","native":"💗"}],"version":1},"heartbeat":{"id":"heartbeat","name":"Beating Heart","keywords":["heartbeat","love","like","affection","valentines","pink"],"skins":[{"unified":"1f493","native":"💓"}],"version":1},"revolving_hearts":{"id":"revolving_hearts","name":"Revolving Hearts","keywords":["love","like","affection","valentines"],"skins":[{"unified":"1f49e","native":"💞"}],"version":1},"two_hearts":{"id":"two_hearts","name":"Two Hearts","keywords":["love","like","affection","valentines","heart"],"skins":[{"unified":"1f495","native":"💕"}],"version":1},"heart_decoration":{"id":"heart_decoration","name":"Heart Decoration","keywords":["purple","square","love","like"],"skins":[{"unified":"1f49f","native":"💟"}],"version":1},"heavy_heart_exclamation_mark_ornament":{"id":"heavy_heart_exclamation_mark_ornament","name":"Heart Exclamation","keywords":["heavy","mark","ornament","decoration","love"],"skins":[{"unified":"2763-fe0f","native":"❣️"}],"version":1},"broken_heart":{"id":"broken_heart","name":"Broken Heart","emoticons":[" Date: Tue, 3 Mar 2026 19:35:09 +0000 Subject: [PATCH 33/43] Set mute word dialog text input return key type to "done" (#9967) Co-authored-by: Claude --- src/components/dialogs/MutedWords.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/dialogs/MutedWords.tsx b/src/components/dialogs/MutedWords.tsx index 9c7b7580a..94c48dcc2 100644 --- a/src/components/dialogs/MutedWords.tsx +++ b/src/components/dialogs/MutedWords.tsx @@ -132,6 +132,7 @@ function MutedWordsInner() { autoCorrect={false} autoCapitalize="none" autoComplete="off" + returnKeyType="done" label={_(msg`Enter a word or tag`)} placeholder={_(msg`Enter a word or tag`)} value={field} -- 2.51.2 From 37a82761f597d165b4e6262d31b21f938edda7c1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 3 Mar 2026 19:50:22 +0000 Subject: [PATCH 34/43] Cache profile view before opening AfterReportDialog (#9962) --- src/components/dms/AfterReportDialog.tsx | 4 ++-- src/components/dms/ConvoMenu.tsx | 13 ++++++++++++- src/components/dms/MessageContextMenu.tsx | 7 ++++++- src/components/moderation/ReportDialog/action.ts | 2 +- src/screens/Messages/components/RequestButtons.tsx | 13 ++++++++++++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/src/components/dms/AfterReportDialog.tsx b/src/components/dms/AfterReportDialog.tsx index ad375be4d..789784615 100644 --- a/src/components/dms/AfterReportDialog.tsx +++ b/src/components/dms/AfterReportDialog.tsx @@ -69,13 +69,13 @@ function DialogInner({ const control = Dialog.useDialogContext() const { data: profile, - isLoading, + isPending, isError, } = useProfileQuery({ did: params.message.sender.did, }) - return isLoading ? ( + return isPending ? ( diff --git a/src/components/dms/ConvoMenu.tsx b/src/components/dms/ConvoMenu.tsx index 6fcca2081..1ea2a90cd 100644 --- a/src/components/dms/ConvoMenu.tsx +++ b/src/components/dms/ConvoMenu.tsx @@ -5,6 +5,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' +import {useQueryClient} from '@tanstack/react-query' import {type NavigationProp} from '#/lib/routes/types' import {type Shadow} from '#/state/cache/types' @@ -13,7 +14,10 @@ import { useMarkAsReadMutation, } from '#/state/queries/messages/conversation' import {useMuteConvo} from '#/state/queries/messages/mute-conversation' -import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import { + unstableCacheProfileView, + useProfileBlockMutationQueue, +} from '#/state/queries/profile' import * as Toast from '#/view/com/util/Toast' import {type ViewStyleProp} from '#/alf' import {atoms as a} from '#/alf' @@ -63,6 +67,7 @@ let ConvoMenu = ({ style?: ViewStyleProp['style'] }): React.ReactNode => { const {_} = useLingui() + const queryClient = useQueryClient() const leaveConvoControl = Prompt.usePromptControl() const reportControl = Prompt.usePromptControl() @@ -125,6 +130,12 @@ let ConvoMenu = ({ }} control={reportControl} onAfterSubmit={() => { + const sender = convo.members.find( + member => member.did === latestReportableMessage.sender.did, + ) + if (sender) { + unstableCacheProfileView(queryClient, sender) + } blockOrDeleteControl.open() }} /> diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index 8bc7e018f..b9eacf1ee 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -4,11 +4,13 @@ import * as Clipboard from 'expo-clipboard' import {type ChatBskyConvoDefs, RichText} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {useQueryClient} from '@tanstack/react-query' import {useTranslate} from '#/lib/hooks/useTranslate' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' +import {unstableCacheProfileView} from '#/state/queries/unstable-profile-cache' import {useSession} from '#/state/session' import * as Toast from '#/view/com/util/Toast' import * as ContextMenu from '#/components/ContextMenu' @@ -36,6 +38,7 @@ export let MessageContextMenu = ({ const {_} = useLingui() const ax = useAnalytics() const {currentAccount} = useSession() + const queryClient = useQueryClient() const convo = useConvoActive() const deleteControl = usePromptControl() const reportControl = usePromptControl() @@ -170,7 +173,6 @@ export let MessageContextMenu = ({ { + if (sender) { + unstableCacheProfileView(queryClient, sender) + } blockOrDeleteControl.open() }} /> diff --git a/src/components/moderation/ReportDialog/action.ts b/src/components/moderation/ReportDialog/action.ts index 7f29cd33f..ee776fd62 100644 --- a/src/components/moderation/ReportDialog/action.ts +++ b/src/components/moderation/ReportDialog/action.ts @@ -102,7 +102,7 @@ export function useSubmitReportMutation() { } if (__DEV__) { - logger.info('Submitting report', { + logger.info('Submitting report (dry run)', { labeler: { handle: labeler.creator.handle, }, diff --git a/src/screens/Messages/components/RequestButtons.tsx b/src/screens/Messages/components/RequestButtons.tsx index 9f9d7236b..82b5b553f 100644 --- a/src/screens/Messages/components/RequestButtons.tsx +++ b/src/screens/Messages/components/RequestButtons.tsx @@ -12,7 +12,10 @@ import {useEmail} from '#/state/email-verification' import {useAcceptConversation} from '#/state/queries/messages/accept-conversation' import {precacheConvoQuery} from '#/state/queries/messages/conversation' import {useLeaveConvo} from '#/state/queries/messages/leave-conversation' -import {useProfileBlockMutationQueue} from '#/state/queries/profile' +import { + unstableCacheProfileView, + useProfileBlockMutationQueue, +} from '#/state/queries/profile' import * as Toast from '#/view/com/util/Toast' import {atoms as a} from '#/alf' import { @@ -53,6 +56,8 @@ export function RejectMenu({ const {_} = useLingui() const shadowedProfile = useProfileShadow(profile) const navigation = useNavigation() + const queryClient = useQueryClient() + const {mutate: leaveConvo} = useLeaveConvo(convo.id, { onMutate: () => { if (currentScreen === 'conversation') { @@ -174,6 +179,12 @@ export function RejectMenu({ }} control={reportControl} onAfterSubmit={() => { + const sender = convo.members.find( + member => member.did === lastMessage.sender.did, + ) + if (sender) { + unstableCacheProfileView(queryClient, sender) + } blockOrDeleteControl.open() }} /> -- 2.51.2 From 7dc3b4fa8eda951fe6de91981d3293e14d237993 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:49:38 -0800 Subject: [PATCH 35/43] Add search event analytics (#9949) Co-authored-by: Eric Bailey --- src/analytics/metrics/types.ts | 26 ++++ src/components/FeedCard.tsx | 48 +++--- src/components/ProfileCard.tsx | 88 +++++------ src/components/forms/SearchInput.tsx | 15 +- src/screens/Search/SearchResults.tsx | 147 ++++++++++++++++-- src/screens/Search/Shell.tsx | 18 ++- .../Search/components/AutocompleteResults.tsx | 8 +- .../Search/components/SearchHistory.tsx | 33 ++-- src/view/com/profile/ProfileCard.tsx | 5 +- src/view/shell/desktop/Search.tsx | 23 ++- 10 files changed, 291 insertions(+), 120 deletions(-) diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index efb002f40..28c3150c2 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -649,6 +649,32 @@ export type Events = { tab: string } + 'search:query': { + source: 'typed' | 'history' | 'autocomplete' + } + + 'search:results:loaded': { + tab: 'top' | 'latest' | 'people' | 'feeds' + initialCount: number + } + + 'search:result:press': { + tab?: 'top' | 'latest' | 'people' | 'feeds' + resultType: 'post' | 'profile' | 'feed' + position: number + uri: string + } + + 'search:recent:press': { + profileDid: string + position: number + } + + 'search:autocomplete:press': { + profileDid: string + position: number + } + 'progressGuide:hide': {} 'progressGuide:followDialog:open': {} diff --git a/src/components/FeedCard.tsx b/src/components/FeedCard.tsx index bf2245599..8c087d17e 100644 --- a/src/components/FeedCard.tsx +++ b/src/components/FeedCard.tsx @@ -1,4 +1,4 @@ -import React, {useMemo} from 'react' +import {useCallback, useEffect, useMemo} from 'react' import {type GestureResponderEvent, View} from 'react-native' import { type AppBskyFeedDefs, @@ -6,9 +6,7 @@ import { AtUri, RichText as RichTextApi, } from '@atproto/api' -import {msg} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' -import {Plural, Trans} from '@lingui/react/macro' +import {Plural, Trans, useLingui} from '@lingui/react/macro' import {useQueryClient} from '@tanstack/react-query' import {sanitizeHandle} from '#/lib/strings/handles' @@ -73,11 +71,11 @@ export function Link({ }: Props & Omit) { const queryClient = useQueryClient() - const href = React.useMemo(() => { + const href = useMemo(() => { return createProfileFeedHref({feed: view}) }, [view]) - React.useEffect(() => { + useEffect(() => { precacheFeedFromGeneratorView(queryClient, view) }, [view, queryClient]) @@ -212,7 +210,7 @@ export function Description({ description, ...rest }: {description?: string} & Partial) { - const rt = React.useMemo(() => { + const rt = useMemo(() => { if (!description) return const rt = new RichTextApi({text: description || ''}) rt.detectFacetsWithoutResolution() @@ -279,7 +277,7 @@ function SaveButtonInner({ pin?: boolean text?: boolean } & Partial) { - const {_} = useLingui() + const {t: l} = useLingui() const {data: preferences} = usePreferencesQuery() const {isPending: isAddSavedFeedPending, mutateAsync: saveFeeds} = useAddSavedFeedsMutation() @@ -289,13 +287,13 @@ function SaveButtonInner({ const uri = view.uri const type = view.uri.includes('app.bsky.feed.generator') ? 'feed' : 'list' - const savedFeedConfig = React.useMemo(() => { + const savedFeedConfig = useMemo(() => { return preferences?.savedFeeds?.find(feed => feed.value === uri) }, [preferences?.savedFeeds, uri]) const removePromptControl = Prompt.usePromptControl() const isPending = isAddSavedFeedPending || isRemovePending - const toggleSave = React.useCallback( + const toggleSave = useCallback( async (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() @@ -312,17 +310,17 @@ function SaveButtonInner({ }, ]) } - Toast.show(_(msg({message: 'Feeds updated!', context: 'toast'}))) + Toast.show(l({message: 'Feeds updated!', context: 'toast'})) } catch (err: any) { logger.error(err, {message: `FeedCard: failed to update feeds`, pin}) - Toast.show(_(msg`Failed to update feeds`), 'xmark') + Toast.show(l`Failed to update feeds`, 'xmark') } }, - [_, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], + [l, pin, saveFeeds, removeFeed, uri, savedFeedConfig, type], ) - const onPrompRemoveFeed = React.useCallback( - async (e: GestureResponderEvent) => { + const onPromptRemoveFeed = useCallback( + (e: GestureResponderEvent) => { e.preventDefault() e.stopPropagation() @@ -335,11 +333,13 @@ function SaveButtonInner({ <> +
+ + + { + handleFallback() + })} + label={l`Try Google Translate`} + hoverStyle={[ + native({opacity: 0.5}), + web([a.underline, {textDecorationColor: t.palette.primary_500}]), + ]} + hitSlop={HITSLOP_30}> + + Try Google Translate + + + ) } function TranslationResult({ + clearTranslation, + translate, postText, sourceLanguage, translatedText, }: { + clearTranslation: () => void + translate: TranslationFunction postText: string sourceLanguage: string | null translatedText: string }) { const t = useTheme() - const {i18n} = useLingui() + const langPrefs = useLanguagePrefs() + const {i18n, t: l} = useLingui() const langName = sourceLanguage ? codeToLanguageName(sourceLanguage, i18n.locale) : undefined return ( - - - {langName ? ( - Translated from {langName} - ) : ( - Translated - )} - {sourceLanguage != null && ( - <> - - {' '} - · - {' '} - - - )} - - - {translatedText} - + + + + {langName ? ( + + + {langName}{' '} + + + + + + {' '} + {codeToLanguageName( + langPrefs.primaryLanguage, + langPrefs.appLanguage, + )} + + + ) : ( + + Translated + + )} + {sourceLanguage != null && ( + <> + + {' '} + ·{' '} + + + + )} + + + {translatedText} + + + ) } function TranslationLanguageSelect({ + translate, postText, sourceLanguage, }: { + translate: TranslationFunction postText: string sourceLanguage: string }) { + const t = useTheme() const ax = useAnalytics() - const {_} = useLingui() + const {t: l} = useLingui() const langPrefs = useLanguagePrefs() - const {translate} = useTranslateOnDevice() const items = useMemo( () => @@ -116,18 +348,21 @@ function TranslationLanguageSelect({ !langPrefs.primaryLanguage.startsWith(lang.code2) && // Don't show the current language as it would be redundant index === self.findIndex(t => t.code2 === lang.code2), // Remove dupes (which will happen due to multiple code3 values mapping to the same code2) ) - .sort( - (a, b) => - languageName(a, langPrefs.appLanguage).localeCompare( - languageName(b, langPrefs.appLanguage), - langPrefs.appLanguage, - ), // Localized sort - ) + .sort((a, b) => { + // Prioritize sourceLanguage at the top + if (a.code2 === sourceLanguage) return -1 + if (b.code2 === sourceLanguage) return 1 + // Localized sort + return languageName(a, langPrefs.appLanguage).localeCompare( + languageName(b, langPrefs.appLanguage), + langPrefs.appLanguage, + ) + }) .map(l => ({ label: languageName(l, langPrefs.appLanguage), // The viewer may not be familiar with the source language, so localize the name value: l.code2, })), - [langPrefs], + [langPrefs, sourceLanguage], ) const handleChangeTranslationLanguage = (sourceLangCode: string) => { @@ -136,24 +371,35 @@ function TranslationLanguageSelect({ sourceLanguage: sourceLangCode, targetLanguage: langPrefs.primaryLanguage, }) - void translate(postText, langPrefs.primaryLanguage, sourceLangCode) + void translate({ + text: postText, + targetLangCode: langPrefs.primaryLanguage, + sourceLangCode, + }) } return ( - + {({props}) => { return ( - - Change - + ) }} ( diff --git a/src/components/PostControls/PostMenu/PostMenuItems.tsx b/src/components/PostControls/PostMenu/PostMenuItems.tsx index 13168bd2a..9f10838ef 100644 --- a/src/components/PostControls/PostMenu/PostMenuItems.tsx +++ b/src/components/PostControls/PostMenu/PostMenuItems.tsx @@ -13,13 +13,12 @@ import { AtUri, type RichText as RichTextAPI, } from '@atproto/api' -import {msg, plural} from '@lingui/core/macro' -import {useLingui} from '@lingui/react' +import {plural} from '@lingui/core/macro' +import {useLingui} from '@lingui/react/macro' import {useNavigation} from '@react-navigation/native' import {DISCOVER_DEBUG_DIDS} from '#/lib/constants' import {useOpenLink} from '#/lib/hooks/useOpenLink' -import {useTranslate} from '#/lib/hooks/useTranslate' import {getCurrentRoute} from '#/lib/routes/helpers' import {makeProfileLink} from '#/lib/routes/links' import { @@ -28,6 +27,7 @@ import { } from '#/lib/routes/types' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {toShareUrl} from '#/lib/strings/url-helpers' +import {useTranslate} from '#/lib/translation' import {logger} from '#/logger' import {type Shadow} from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' @@ -106,6 +106,7 @@ let PostMenuItems = ({ threadgateRecord, onShowLess, logContext, + forceGoogleTranslate, }: { testID: string post: Shadow @@ -120,9 +121,10 @@ let PostMenuItems = ({ threadgateRecord?: AppBskyFeedThreadgate.Record onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + forceGoogleTranslate: boolean }): React.ReactNode => { const {hasSession, currentAccount} = useSession() - const {_} = useLingui() + const {t: l} = useLingui() const ax = useAnalytics() const langPrefs = useLanguagePrefs() const {mutateAsync: deletePostMutate} = usePostDeleteMutation() @@ -133,7 +135,10 @@ let PostMenuItems = ({ const {hidePost} = useHiddenPostsApi() const feedFeedback = useFeedFeedbackContext() const openLink = useOpenLink() - const translate = useTranslate() + const {clearTranslation, translate, translationState} = useTranslate({ + key: post.uri, + forceGoogleTranslate, + }) const navigation = useNavigation() const {mutedWordsDialogControl} = useGlobalDialogsControlContext() const blockPromptControl = useDialogControl() @@ -191,7 +196,7 @@ let PostMenuItems = ({ const onDeletePost = () => { deletePostMutate({uri: postUri}).then( () => { - Toast.show(_(msg({message: 'Post deleted', context: 'toast'}))) + Toast.show(l({message: 'Post deleted', context: 'toast'})) const route = getCurrentRoute(navigation.getState()) if (route.name === 'PostThread') { @@ -211,7 +216,7 @@ let PostMenuItems = ({ }, e => { logger.error('Failed to delete post', {message: e}) - Toast.show(_(msg`Failed to delete post, please try again`), 'xmark') + Toast.show(l`Failed to delete post, please try again`, 'xmark') }, ) } @@ -226,7 +231,7 @@ let PostMenuItems = ({ logContext, feedDescriptor: feedFeedback.feedDescriptor, }) - Toast.show(_(msg`You will now receive notifications for this thread`)) + Toast.show(l`You will now receive notifications for this thread`) } else { void muteThread() ax.metric('post:mute', { @@ -235,18 +240,13 @@ let PostMenuItems = ({ logContext, feedDescriptor: feedFeedback.feedDescriptor, }) - Toast.show( - _(msg`You will no longer receive notifications for this thread`), - ) + Toast.show(l`You will no longer receive notifications for this thread`) } } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to toggle thread mute', {message: e}) - Toast.show( - _(msg`Failed to toggle thread mute, please try again`), - 'xmark', - ) + Toast.show(l`Failed to toggle thread mute, please try again`, 'xmark') } } } @@ -255,11 +255,14 @@ let PostMenuItems = ({ const str = richTextToString(richText, true) void Clipboard.setStringAsync(str) - Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') + Toast.show(l`Copied to clipboard`, 'clipboard-check') } const onPressTranslate = () => { - void translate(record.text, langPrefs.primaryLanguage) + void translate({ + text: record.text, + targetLangCode: langPrefs.primaryLanguage, + }) if ( bsky.dangerousIsType( @@ -297,9 +300,7 @@ let PostMenuItems = ({ logContext, feedDescriptor: feedFeedback.feedDescriptor, }) - Toast.show( - _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), - ) + Toast.show(l({message: 'Feedback sent to feed operator', context: 'toast'})) } const onPressShowLess = () => { @@ -322,7 +323,7 @@ let PostMenuItems = ({ }) } else { Toast.show( - _(msg({message: 'Feedback sent to feed operator', context: 'toast'})), + l({message: 'Feedback sent to feed operator', context: 'toast'}), ) } } @@ -341,13 +342,13 @@ let PostMenuItems = ({ }) Toast.show( isDetach - ? _(msg`Quote post was successfully detached`) - : _(msg`Quote post was re-attached`), + ? l`Quote post was successfully detached` + : l`Quote post was re-attached`, ) } catch (err) { const e = err as Error Toast.show( - _(msg({message: 'Updating quote attachment failed', context: 'toast'})), + l({message: 'Updating quote attachment failed', context: 'toast'}), ) logger.error(`Failed to ${action} quote`, {safeMessage: e.message}) } @@ -379,31 +380,27 @@ let PostMenuItems = ({ Toast.show( isHide - ? _(msg`Reply was successfully hidden`) - : _(msg({message: 'Reply visibility updated', context: 'toast'})), + ? l`Reply was successfully hidden` + : l({message: 'Reply visibility updated', context: 'toast'}), ) } catch (err) { const e = err as Error if (e instanceof MaxHiddenRepliesError) { Toast.show( - _( - plural(MAX_HIDDEN_REPLIES, { - other: 'You can hide a maximum of # replies.', - }), - ), + plural(MAX_HIDDEN_REPLIES, { + other: 'You can hide a maximum of # replies.', + }), ) } else if (e instanceof InvalidInteractionSettingsError) { Toast.show( - _(msg({message: 'Invalid interaction settings.', context: 'toast'})), + l({message: 'Invalid interaction settings.', context: 'toast'}), ) } else { Toast.show( - _( - msg({ - message: 'Updating reply visibility failed', - context: 'toast', - }), - ), + l({ + message: 'Updating reply visibility failed', + context: 'toast', + }), ) logger.error(`Failed to ${action} reply`, {safeMessage: e.message}) } @@ -422,12 +419,12 @@ let PostMenuItems = ({ const onBlockAuthor = async () => { try { await queueBlock() - Toast.show(_(msg({message: 'Account blocked', context: 'toast'}))) + Toast.show(l({message: 'Account blocked', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to block account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') } } } @@ -436,23 +433,23 @@ let PostMenuItems = ({ if (postAuthor.viewer?.muted) { try { await queueUnmute() - Toast.show(_(msg({message: 'Account unmuted', context: 'toast'}))) + Toast.show(l({message: 'Account unmuted', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to unmute account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') } } } else { try { await queueMute() - Toast.show(_(msg({message: 'Account muted', context: 'toast'}))) + Toast.show(l({message: 'Account muted', context: 'toast'})) } catch (err) { const e = err as Error if (e?.name !== 'AbortError') { logger.error('Failed to mute account', {message: e}) - Toast.show(_(msg`There was an issue! ${e.toString()}`), 'xmark') + Toast.show(l`There was an issue! ${e.toString()}`, 'xmark') } } } @@ -467,6 +464,8 @@ let PostMenuItems = ({ const onSignIn = () => requireSignIn(() => {}) + const onPressHideTranslation = () => clearTranslation() + const isDiscoverDebugUser = IS_INTERNAL || DISCOVER_DEBUG_DIDS[currentAccount?.did || ''] || @@ -481,16 +480,12 @@ let PostMenuItems = ({ - {isPinned - ? _(msg`Unpin from profile`) - : _(msg`Pin to your profile`)} + {isPinned ? l`Unpin from profile` : l`Pin to your profile`} {!hideInPWI || hasSession ? ( <> - - {_(msg`Translate`)} - - + {translationState.status === 'loading' ? ( + {}}> + {l`Translating…`} + + + ) : translationState.status === 'success' ? ( + + {l`Hide translation`} + + + ) : ( + + {l`Translate`} + + + )} - {_(msg`Copy post text`)} + {l`Copy post text`} ) : ( - {_(msg`Sign in to view post`)} + {l`Sign in to view post`} )} @@ -538,17 +551,17 @@ let PostMenuItems = ({ - {_(msg`Show more like this`)} + {l`Show more like this`} - {_(msg`Show less like this`)} + {l`Show less like this`} @@ -560,9 +573,9 @@ let PostMenuItems = ({ - {_(msg`Assign topic for algo`)} + {l`Assign topic for algo`} @@ -574,12 +587,10 @@ let PostMenuItems = ({ - {isThreadMuted ? _(msg`Unmute thread`) : _(msg`Mute thread`)} + {isThreadMuted ? l`Unmute thread` : l`Mute thread`} mutedWordsDialogControl.open()}> - {_(msg`Mute words & tags`)} + {l`Mute words & tags`} @@ -606,16 +617,10 @@ let PostMenuItems = ({ {canHidePostForMe && ( hidePromptControl.open()}> - {isReply - ? _(msg`Hide reply for me`) - : _(msg`Hide post for me`)} + {isReply ? l`Hide reply for me` : l`Hide post for me`} @@ -625,8 +630,8 @@ let PostMenuItems = ({ testID="postDropdownHideBtn" label={ isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`) + ? l`Show reply for everyone` + : l`Hide reply for everyone` } onPress={ isReplyHiddenByThreadgate @@ -635,8 +640,8 @@ let PostMenuItems = ({ }> {isReplyHiddenByThreadgate - ? _(msg`Show reply for everyone`) - : _(msg`Hide reply for everyone`)} + ? l`Show reply for everyone` + : l`Hide reply for everyone`} {quoteEmbed.isDetached - ? _(msg`Re-attach quote`) - : _(msg`Detach quote`)} + ? l`Re-attach quote` + : l`Detach quote`} void onMuteAuthor()}> {postAuthor.viewer?.muted - ? _(msg`Unmute account`) - : _(msg`Mute account`)} + ? l`Unmute account` + : l`Mute account`} blockPromptControl.open()}> - {_(msg`Block account`)} + {l`Block account`} )} reportDialogControl.open()}> - {_(msg`Report post`)} + {l`Report post`} @@ -729,7 +734,7 @@ let PostMenuItems = ({ <> postInteractionSettingsDialogControl.open()} {...(isAuthor ? Platform.select({ @@ -742,15 +747,15 @@ let PostMenuItems = ({ }) : {})}> - {_(msg`Edit interaction settings`)} + {l`Edit interaction settings`} deletePromptControl.open()}> - {_(msg`Delete post`)} + {l`Delete post`} @@ -759,28 +764,21 @@ let PostMenuItems = ({ )} - - diff --git a/src/components/dms/MessageContextMenu.tsx b/src/components/dms/MessageContextMenu.tsx index b9eacf1ee..ea5b4a48b 100644 --- a/src/components/dms/MessageContextMenu.tsx +++ b/src/components/dms/MessageContextMenu.tsx @@ -6,7 +6,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useTranslate} from '#/lib/hooks/useTranslate' +import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' import {richTextToString} from '#/lib/strings/rich-text-helpers' import {useConvoActive} from '#/state/messages/convo' import {useLanguagePrefs} from '#/state/preferences' @@ -44,7 +44,7 @@ export let MessageContextMenu = ({ const reportControl = usePromptControl() const blockOrDeleteControl = usePromptControl() const langPrefs = useLanguagePrefs() - const translate = useTranslate() + const translate = useGoogleTranslate() const isFromSelf = message.sender?.did === currentAccount?.did @@ -57,12 +57,12 @@ export let MessageContextMenu = ({ true, ) - Clipboard.setStringAsync(str) + void Clipboard.setStringAsync(str) Toast.show(_(msg`Copied to clipboard`), 'clipboard-check') }, [_, message.text, message.facets]) const onPressTranslateMessage = useCallback(() => { - translate(message.text, langPrefs.primaryLanguage) + void translate(message.text, langPrefs.primaryLanguage) ax.metric('translate', { sourceLanguages: [], diff --git a/src/env/index.ts b/src/env/index.ts index 14abba55a..d11beafcf 100644 --- a/src/env/index.ts +++ b/src/env/index.ts @@ -49,3 +49,6 @@ export const IS_WEB_FIREFOX: boolean = false export const IS_HIGH_DPI: boolean = true // ideally we'd use isLiquidGlassAvailable() from expo-glass-effect but checking iOS version is good enough for now export const IS_LIQUID_GLASS: boolean = iOSMajorVersion >= 26 +// So we can avoid attempting on-device translation when we know it's unsupported. +export const HAS_ON_DEVICE_TRANSLATION: boolean = + (IS_IOS && iOSMajorVersion >= 18) || IS_ANDROID diff --git a/src/env/index.web.ts b/src/env/index.web.ts index 0a078fdeb..ee7462ea8 100644 --- a/src/env/index.web.ts +++ b/src/env/index.web.ts @@ -48,3 +48,4 @@ export const IS_HIGH_DPI: boolean = window.matchMedia( '(min-resolution: 2dppx)', ).matches export const IS_LIQUID_GLASS: boolean = false +export const HAS_ON_DEVICE_TRANSLATION: boolean = false diff --git a/src/lib/hooks/useTranslate.ts b/src/lib/hooks/useGoogleTranslate.ts similarity index 93% rename from src/lib/hooks/useTranslate.ts rename to src/lib/hooks/useGoogleTranslate.ts index 233a2ae8b..70f1637bd 100644 --- a/src/lib/hooks/useTranslate.ts +++ b/src/lib/hooks/useGoogleTranslate.ts @@ -6,10 +6,9 @@ import {getTranslatorLink} from '#/locale/helpers' import {IS_ANDROID} from '#/env' /** - * Will always link out to Google Translate. If inline translation is desired, - * use `useTranslateOnDevice` + * @deprecated Will always link out to Google Translate. Prefer `useTranslate`. */ -export function useTranslate() { +export function useGoogleTranslate() { const openLink = useOpenLink() return useCallback( diff --git a/src/lib/translation/context.ts b/src/lib/translation/context.ts new file mode 100644 index 000000000..423efc4f1 --- /dev/null +++ b/src/lib/translation/context.ts @@ -0,0 +1,16 @@ +import {createContext} from 'react' + +import {type TranslationFunctionParams, type TranslationState} from './types' + +export const Context = createContext<{ + translationState: Record + translate: ( + parameters: TranslationFunctionParams & { + key: string + forceGoogleTranslate: boolean + }, + ) => Promise + clearTranslation: (key: string) => void + acquireTranslation: (key: string) => () => void +} | null>(null) +Context.displayName = 'TranslationContext' diff --git a/src/lib/translation/index.tsx b/src/lib/translation/index.tsx new file mode 100644 index 000000000..d6c8a5173 --- /dev/null +++ b/src/lib/translation/index.tsx @@ -0,0 +1,282 @@ +import {useCallback, useContext, useEffect, useMemo, useState} from 'react' +import {LayoutAnimation, Platform} from 'react-native' +import {getLocales} from 'expo-localization' +import {onTranslateTask} from '@bsky.app/expo-translate-text' +import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' +import {useLingui} from '@lingui/react/macro' +import {useFocusEffect} from '@react-navigation/native' + +import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' +import {logger} from '#/logger' +import {useAnalytics} from '#/analytics' +import {HAS_ON_DEVICE_TRANSLATION} from '#/env' +import {Context} from './context' +import {type TranslationFunctionParams, type TranslationState} from './types' +import {guessLanguage} from './utils' + +export * from './types' +export * from './utils' + +/** + * Attempts on-device translation via @bsky.app/expo-translate-text. + * Uses a lazy import to avoid crashing if the native module isn't linked into + * the current build. + */ +async function attemptTranslation( + input: string, + targetLangCodeOriginal: string, + sourceLangCodeOriginal?: string, // Auto-detects if not provided +): Promise<{ + translatedText: string + targetLanguage: TranslationTaskResult['targetLanguage'] + sourceLanguage: TranslationTaskResult['sourceLanguage'] +}> { + // Note that Android only supports two-character language codes and will fail + // on other input. + // https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage + let targetLangCode = + Platform.OS === 'android' + ? targetLangCodeOriginal.split('-')[0] + : targetLangCodeOriginal + const sourceLangCode = + Platform.OS === 'android' + ? sourceLangCodeOriginal?.split('-')[0] + : sourceLangCodeOriginal + + // Special cases for regional languages since iOS differentiates and missing + // language packs must be downloaded and installed. + if (Platform.OS === 'ios') { + const deviceLocales = getLocales() + const primaryLanguageTag = deviceLocales[0]?.languageTag + switch (targetLangCodeOriginal) { + case 'en': // en-US, en-GB + case 'es': // es-419, es-ES + case 'pt': // pt-BR, pt-PT + case 'zh': // zh-Hans-CN, zh-Hant-HK, zh-Hant-TW + if ( + primaryLanguageTag && + primaryLanguageTag.startsWith(targetLangCodeOriginal) + ) { + targetLangCode = primaryLanguageTag + } + break + } + } + + const result = await onTranslateTask({ + input, + targetLangCode, + sourceLangCode, + }) + + // Since `input` is always a string, the result should always be a string. + const translatedText = + typeof result.translatedTexts === 'string' ? result.translatedTexts : '' + + if (translatedText === input) { + throw new Error('Translation result is the same as the source text.') + } + + if (translatedText === '') { + throw new Error('Translation result is empty.') + } + + return { + translatedText, + targetLanguage: result.targetLanguage, + sourceLanguage: + result.sourceLanguage ?? sourceLangCode ?? guessLanguage(input), // iOS doesn't return the source language + } +} + +/** + * Native translation hook. Attempts on-device translation using Apple + * Translation (iOS 18+) or Google ML Kit (Android). + * + * Falls back to Google Translate URL if the language pack is unavailable. + * + * Web uses index.web.ts which always opens Google Translate. + */ +export function useTranslate({ + key, + forceGoogleTranslate = false, +}: { + key: string + forceGoogleTranslate?: boolean +}) { + const context = useContext(Context) + if (!context) { + throw new Error( + 'useTranslate must be used within a TranslateOnDeviceProvider', + ) + } + + useFocusEffect( + useCallback(() => { + const cleanup = context.acquireTranslation(key) + return cleanup + }, [key, context]), + ) + + const translate = useCallback( + async (params: TranslationFunctionParams) => { + return context.translate({...params, key, forceGoogleTranslate}) + }, + [context, forceGoogleTranslate, key], + ) + + const clearTranslation = useCallback( + () => context.clearTranslation(key), + [context, key], + ) + + return useMemo( + () => ({ + translationState: context.translationState[key] ?? { + status: 'idle', + }, + translate, + clearTranslation, + }), + [clearTranslation, context.translationState, key, translate], + ) +} + +export function Provider({children}: React.PropsWithChildren) { + const [translationState, setTranslationState] = useState< + Record + >({}) + const [refCounts, setRefCounts] = useState>({}) + const ax = useAnalytics() + const {t: l} = useLingui() + const googleTranslate = useGoogleTranslate() + + useEffect(() => { + setTranslationState(prev => { + const keysToDelete: string[] = [] + + for (const key of Object.keys(prev)) { + if ((refCounts[key] ?? 0) <= 0) { + keysToDelete.push(key) + } + } + + if (keysToDelete.length > 0) { + const newState = {...prev} + keysToDelete.forEach(key => { + delete newState[key] + }) + return newState + } + + return prev + }) + }, [refCounts]) + + const acquireTranslation = useCallback((key: string) => { + setRefCounts(prev => ({ + ...prev, + [key]: (prev[key] ?? 0) + 1, + })) + + return () => { + setRefCounts(prev => { + const newCount = (prev[key] ?? 1) - 1 + if (newCount <= 0) { + const {[key]: _, ...rest} = prev + return rest + } + return {...prev, [key]: newCount} + }) + } + }, []) + + const clearTranslation = useCallback((key: string) => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState(prev => { + delete prev[key] + return {...prev} + }) + }, []) + + const translate = useCallback( + async ({ + key, + text, + targetLangCode, + sourceLangCode, + ...options + }: { + key: string + text: string + targetLangCode: string + sourceLangCode?: string + forceGoogleTranslate?: boolean + }) => { + if (options?.forceGoogleTranslate || !HAS_ON_DEVICE_TRANSLATION) { + ax.metric('translate:result', { + method: 'google-translate', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + await googleTranslate(text, targetLangCode, sourceLangCode) + return + } + + // Translate after the next state change. + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState(prev => ({ + ...prev, + [key]: {status: 'loading'}, + })) + try { + const result = await attemptTranslation( + text, + targetLangCode, + sourceLangCode, + ) + ax.metric('translate:result', { + method: 'on-device', + os: Platform.OS, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + }) + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState(prev => ({ + ...prev, + [key]: { + status: 'success', + translatedText: result.translatedText, + sourceLanguage: result.sourceLanguage, + targetLanguage: result.targetLanguage, + }, + })) + } catch (e) { + logger.error('Failed to translate post on device', {safeMessage: e}) + // On-device translation failed (language pack missing or user + // dismissed the download prompt). Fall back to Google Translate. + ax.metric('translate:result', { + method: 'fallback-alert', + os: Platform.OS, + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + let errorMessage = l`Device failed to translate :(` + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + setTranslationState(prev => ({ + ...prev, + [key]: {status: 'error', message: errorMessage}, + })) + } + }, + [ax, googleTranslate, l], + ) + + const ctx = useMemo( + () => ({acquireTranslation, clearTranslation, translate, translationState}), + [acquireTranslation, clearTranslation, translate, translationState], + ) + + return {children} +} diff --git a/src/lib/translation/index.web.tsx b/src/lib/translation/index.web.tsx new file mode 100644 index 000000000..8fc46175f --- /dev/null +++ b/src/lib/translation/index.web.tsx @@ -0,0 +1,86 @@ +import {useCallback, useContext, useMemo} from 'react' + +import {useGoogleTranslate} from '#/lib/hooks/useGoogleTranslate' +import {useAnalytics} from '#/analytics' +import {Context} from './context' +import {type TranslationFunctionParams, type TranslationState} from './types' + +export * from './types' +export * from './utils' + +const translationState: Record = {} +const acquireTranslation = (_key: string) => { + return () => {} +} +const clearTranslation = (_key: string) => {} + +/** + * Web always opens Google Translate. + */ +export function useTranslate({ + key, +}: { + key: string + forceGoogleTranslate?: boolean +}) { + const context = useContext(Context) + if (!context) { + throw new Error( + 'useTranslate must be used within a TranslateOnDeviceProvider', + ) + } + + // Always call hooks in consistent order + const translate = useCallback( + async (params: TranslationFunctionParams) => { + return context.translate({...params, key, forceGoogleTranslate: true}) + }, + [key, context], + ) + + const clearTranslation = useCallback(() => { + return context.clearTranslation(key) + }, [key, context]) + + return { + translationState: context.translationState[key] ?? { + status: 'idle' as const, + }, + translate, + clearTranslation, + } +} + +export function Provider({children}: React.PropsWithChildren) { + const ax = useAnalytics() + const googleTranslate = useGoogleTranslate() + + const translate = useCallback( + async ({ + text, + targetLangCode, + sourceLangCode, + }: { + key: string + text: string + targetLangCode: string + sourceLangCode?: string + }) => { + ax.metric('translate:result', { + method: 'google-translate', + os: 'web', + sourceLanguage: sourceLangCode ?? null, + targetLanguage: targetLangCode, + }) + await googleTranslate(text, targetLangCode, sourceLangCode) + }, + [ax, googleTranslate], + ) + + const ctx = useMemo( + () => ({acquireTranslation, clearTranslation, translate, translationState}), + [translate], + ) + + return {children} +} diff --git a/src/lib/translation/types.ts b/src/lib/translation/types.ts new file mode 100644 index 000000000..4bf2d021e --- /dev/null +++ b/src/lib/translation/types.ts @@ -0,0 +1,34 @@ +import {type TranslationTaskResult} from '@bsky.app/expo-translate-text/build/ExpoTranslateText.types' + +export type TranslationState = + | {status: 'idle'} + | {status: 'loading'} + | { + status: 'success' + translatedText: string + sourceLanguage: TranslationTaskResult['sourceLanguage'] + targetLanguage: TranslationTaskResult['targetLanguage'] + } + | { + status: 'error' + message: string + } + +export type TranslationFunctionParams = { + /** + * The text to be translated. + */ + text: string + /** + * The language to translate the text into. + */ + targetLangCode: string + /** + * The source language of the text. Will auto-detect if not provided. + */ + sourceLangCode?: string +} + +export type TranslationFunction = ( + parameters: TranslationFunctionParams, +) => Promise diff --git a/src/lib/translation/utils.ts b/src/lib/translation/utils.ts new file mode 100644 index 000000000..0a3ab9121 --- /dev/null +++ b/src/lib/translation/utils.ts @@ -0,0 +1,13 @@ +import lande from 'lande' + +import {code3ToCode2Strict} from '#/locale/helpers' + +// TODO: Replace with expo-guess-language +export function guessLanguage(text: string): string | null { + const results = lande(text) + // only return high-confidence results + if (results[0] && results[0][1] > 0.97) { + return code3ToCode2Strict(results[0][0]) ?? null + } + return null +} diff --git a/src/locale/languages.ts b/src/locale/languages.ts index 9d8008f01..af7ae9e20 100644 --- a/src/locale/languages.ts +++ b/src/locale/languages.ts @@ -2,8 +2,6 @@ export interface Language { code3: string code2: string name: string - android: boolean - ios: boolean } export enum AppLanguage { @@ -103,3543 +101,2531 @@ export const APP_LANGUAGES: AppLanguageConfig[] = [ ] // Pre-generated list using Intl.DisplayNames to localize the language name. -// https://developers.google.com/android/reference/com/google/mlkit/nl/translate/TranslateLanguage -// https://developer.apple.com/documentation/foundation/nslocale/isolanguagecodes export const LANGUAGES: Language[] = [ { code3: 'aar', code2: 'aa', name: 'Afar', - android: false, - ios: false, }, { code3: 'abk', code2: 'ab', name: 'Abkhazian', - android: false, - ios: false, }, { code3: 'ace', code2: '', name: 'Achinese', - android: false, - ios: false, }, { code3: 'ach', code2: '', name: 'Acoli', - android: false, - ios: false, }, { code3: 'ada', code2: '', name: 'Adangme', - android: false, - ios: false, }, { code3: 'ady', code2: '', name: 'Adyghe; Adygei', - android: false, - ios: false, }, { code3: 'afa', code2: '', name: 'Afro-Asiatic languages', - android: false, - ios: false, }, { code3: 'afh', code2: '', name: 'Afrihili', - android: false, - ios: false, }, { code3: 'afr', code2: 'af', name: 'Afrikaans', - android: false, - ios: false, }, { code3: 'ain', code2: '', name: 'Ainu', - android: false, - ios: false, }, { code3: 'aka', code2: 'ak', name: 'Akan', - android: false, - ios: false, }, { code3: 'akk', code2: '', name: 'Akkadian', - android: false, - ios: false, }, { code3: 'alb', code2: 'sq', name: 'Albanian', - android: true, - ios: false, }, { code3: 'ale', code2: '', name: 'Aleut', - android: false, - ios: false, }, { code3: 'alg', code2: '', name: 'Algonquian languages', - android: false, - ios: false, }, { code3: 'alt', code2: '', name: 'Southern Altai', - android: false, - ios: false, }, { code3: 'amh', code2: 'am', name: 'Amharic', - android: false, - ios: false, }, { code3: 'ang', code2: '', name: 'English, Old (ca.450-1100)', - android: false, - ios: false, }, { code3: 'anp', code2: '', name: 'Angika', - android: false, - ios: false, }, { code3: 'apa', code2: '', name: 'Apache languages', - android: false, - ios: false, }, { code3: 'ara', code2: 'ar', name: 'Arabic', - android: true, - ios: false, }, { code3: 'arc', code2: '', name: 'Official Aramaic (700-300 BCE); Imperial Aramaic (700-300 BCE)', - android: false, - ios: false, }, { code3: 'arg', code2: 'an', name: 'Aragonese', - android: false, - ios: false, }, { code3: 'arm', code2: 'hy', name: 'Armenian', - android: false, - ios: false, }, { code3: 'arn', code2: '', name: 'Mapudungun; Mapuche', - android: false, - ios: false, }, { code3: 'arp', code2: '', name: 'Arapaho', - android: false, - ios: false, }, { code3: 'art', code2: '', name: 'Artificial languages', - android: false, - ios: false, }, { code3: 'arw', code2: '', name: 'Arawak', - android: false, - ios: false, }, { code3: 'asm', code2: 'as', name: 'Assamese', - android: false, - ios: false, }, { code3: 'ast', code2: '', name: 'Asturian', - android: false, - ios: false, }, { code3: 'ath', code2: '', name: 'Athapascan languages', - android: false, - ios: false, }, { code3: 'aus', code2: '', name: 'Australian languages', - android: false, - ios: false, }, { code3: 'ava', code2: 'av', name: 'Avaric', - android: false, - ios: false, }, { code3: 'ave', code2: 'ae', name: 'Avestan', - android: false, - ios: false, }, { code3: 'awa', code2: '', name: 'Awadhi', - android: false, - ios: false, }, { code3: 'aym', code2: 'ay', name: 'Aymara', - android: false, - ios: false, }, { code3: 'aze', code2: 'az', name: 'Azerbaijani', - android: false, - ios: false, }, { code3: 'bad', code2: '', name: 'Banda languages', - android: false, - ios: false, }, { code3: 'bai', code2: '', name: 'Bamileke languages', - android: false, - ios: false, }, { code3: 'bak', code2: 'ba', name: 'Bashkir', - android: false, - ios: false, }, { code3: 'bal', code2: '', name: 'Baluchi', - android: false, - ios: false, }, { code3: 'bam', code2: 'bm', name: 'Bambara', - android: false, - ios: false, }, { code3: 'ban', code2: '', name: 'Balinese', - android: false, - ios: false, }, { code3: 'baq', code2: 'eu', name: 'Basque', - android: false, - ios: false, }, { code3: 'bas', code2: '', name: 'Basa', - android: false, - ios: false, }, { code3: 'bat', code2: '', name: 'Baltic languages', - android: false, - ios: false, }, { code3: 'bej', code2: '', name: 'Beja; Bedawiyet', - android: false, - ios: false, }, { code3: 'bel', code2: 'be', name: 'Belarusian', - android: true, - ios: false, }, { code3: 'bem', code2: '', name: 'Bemba', - android: false, - ios: false, }, { code3: 'ben', code2: 'bn', name: 'Bangla', - android: true, - ios: false, }, { code3: 'ber', code2: '', name: 'Berber languages', - android: false, - ios: false, }, { code3: 'bho', code2: '', name: 'Bhojpuri', - android: false, - ios: false, }, { code3: 'bih', code2: 'bh', name: 'Bhojpuri', - android: false, - ios: false, }, { code3: 'bik', code2: '', name: 'Bikol', - android: false, - ios: false, }, { code3: 'bin', code2: '', name: 'Bini; Edo', - android: false, - ios: false, }, { code3: 'bis', code2: 'bi', name: 'Bislama', - android: false, - ios: false, }, { code3: 'bla', code2: '', name: 'Siksika', - android: false, - ios: false, }, { code3: 'bnt', code2: '', name: 'Bantu languages', - android: false, - ios: false, }, { code3: 'bod', code2: 'bo', name: 'Tibetan', - android: false, - ios: false, }, { code3: 'bos', code2: 'bs', name: 'Bosnian', - android: false, - ios: false, }, { code3: 'bra', code2: '', name: 'Braj', - android: false, - ios: false, }, { code3: 'bre', code2: 'br', name: 'Breton', - android: false, - ios: false, }, { code3: 'btk', code2: '', name: 'Batak languages', - android: false, - ios: false, }, { code3: 'bua', code2: '', name: 'Buriat', - android: false, - ios: false, }, { code3: 'bug', code2: '', name: 'Buginese', - android: false, - ios: false, }, { code3: 'bul', code2: 'bg', name: 'Bulgarian', - android: true, - ios: false, }, { code3: 'bur', code2: 'my', name: 'Burmese', - android: false, - ios: false, }, { code3: 'byn', code2: '', name: 'Blin; Bilin', - android: false, - ios: false, }, { code3: 'cad', code2: '', name: 'Caddo', - android: false, - ios: false, }, { code3: 'cai', code2: '', name: 'Central American Indian languages', - android: false, - ios: false, }, { code3: 'car', code2: '', name: 'Galibi Carib', - android: false, - ios: false, }, { code3: 'cat', code2: 'ca', name: 'Catalan', - android: true, - ios: false, }, { code3: 'cau', code2: '', name: 'Caucasian languages', - android: false, - ios: false, }, { code3: 'ceb', code2: '', name: 'Cebuano', - android: false, - ios: false, }, { code3: 'cel', code2: '', name: 'Celtic languages', - android: false, - ios: false, }, { code3: 'ces', code2: 'cs', name: 'Czech', - android: true, - ios: false, }, { code3: 'cha', code2: 'ch', name: 'Chamorro', - android: false, - ios: false, }, { code3: 'chb', code2: '', name: 'Chibcha', - android: false, - ios: false, }, { code3: 'che', code2: 'ce', name: 'Chechen', - android: false, - ios: false, }, { code3: 'chg', code2: '', name: 'Chagatai', - android: false, - ios: false, }, { code3: 'chi', code2: 'zh', name: 'Chinese', - android: true, - ios: false, }, { code3: 'chk', code2: '', name: 'Chuukese', - android: false, - ios: false, }, { code3: 'chm', code2: '', name: 'Mari', - android: false, - ios: false, }, { code3: 'chn', code2: '', name: 'Chinook jargon', - android: false, - ios: false, }, { code3: 'cho', code2: '', name: 'Choctaw', - android: false, - ios: false, }, { code3: 'chp', code2: '', name: 'Chipewyan; Dene Suline', - android: false, - ios: false, }, { code3: 'chr', code2: '', name: 'Cherokee', - android: false, - ios: false, }, { code3: 'chu', code2: 'cu', name: 'Church Slavic', - android: false, - ios: false, }, { code3: 'chv', code2: 'cv', name: 'Chuvash', - android: false, - ios: false, }, { code3: 'chy', code2: '', name: 'Cheyenne', - android: false, - ios: false, }, { code3: 'cmc', code2: '', name: 'Chamic languages', - android: false, - ios: false, }, { code3: 'cnr', code2: '', name: 'Serbian (Montenegro)', - android: false, - ios: false, }, { code3: 'cop', code2: '', name: 'Coptic', - android: false, - ios: false, }, { code3: 'cor', code2: 'kw', name: 'Cornish', - android: false, - ios: false, }, { code3: 'cos', code2: 'co', name: 'Corsican', - android: false, - ios: false, }, { code3: 'cpe', code2: '', name: 'Creoles and pidgins, English based', - android: false, - ios: false, }, { code3: 'cpf', code2: '', name: 'Creoles and pidgins, French-based', - android: false, - ios: false, }, { code3: 'cpp', code2: '', name: 'Creoles and pidgins, Portuguese-based', - android: false, - ios: false, }, { code3: 'cre', code2: 'cr', name: 'Cree', - android: false, - ios: false, }, { code3: 'crh', code2: '', name: 'Crimean Tatar; Crimean Turkish', - android: false, - ios: false, }, { code3: 'crp', code2: '', name: 'Creoles and pidgins', - android: false, - ios: false, }, { code3: 'csb', code2: '', name: 'Kashubian', - android: false, - ios: false, }, { code3: 'cus', code2: '', name: 'Cushitic languages', - android: false, - ios: false, }, { code3: 'cym', code2: 'cy', name: 'Welsh', - android: true, - ios: false, }, { code3: 'cze', code2: 'cs', name: 'Czech', - android: true, - ios: false, }, { code3: 'dak', code2: '', name: 'Dakota', - android: false, - ios: false, }, { code3: 'dan', code2: 'da', name: 'Danish', - android: true, - ios: false, }, { code3: 'dar', code2: '', name: 'Dargwa', - android: false, - ios: false, }, { code3: 'day', code2: '', name: 'Land Dayak languages', - android: false, - ios: false, }, { code3: 'del', code2: '', name: 'Delaware', - android: false, - ios: false, }, { code3: 'den', code2: '', name: 'Slave (Athapascan)', - android: false, - ios: false, }, { code3: 'deu', code2: 'de', name: 'German', - android: true, - ios: true, }, { code3: 'dgr', code2: '', name: 'Dogrib', - android: false, - ios: false, }, { code3: 'din', code2: '', name: 'Dinka', - android: false, - ios: false, }, { code3: 'div', code2: 'dv', name: 'Divehi', - android: false, - ios: false, }, { code3: 'doi', code2: '', name: 'Dogri', - android: false, - ios: false, }, { code3: 'dra', code2: '', name: 'Dravidian languages', - android: false, - ios: false, }, { code3: 'dsb', code2: '', name: 'Lower Sorbian', - android: false, - ios: false, }, { code3: 'dua', code2: '', name: 'Duala', - android: false, - ios: false, }, { code3: 'dum', code2: '', name: 'Dutch, Middle (ca.1050-1350)', - android: false, - ios: false, }, { code3: 'dut', code2: 'nl', name: 'Dutch', - android: true, - ios: true, }, { code3: 'dyu', code2: '', name: 'Dyula', - android: false, - ios: false, }, { code3: 'dzo', code2: 'dz', name: 'Dzongkha', - android: false, - ios: false, }, { code3: 'efi', code2: '', name: 'Efik', - android: false, - ios: false, }, { code3: 'egy', code2: '', name: 'Egyptian (Ancient)', - android: false, - ios: false, }, { code3: 'eka', code2: '', name: 'Ekajuk', - android: false, - ios: false, }, { code3: 'ell', code2: 'el', name: 'Greek', - android: true, - ios: false, }, { code3: 'elx', code2: '', name: 'Elamite', - android: false, - ios: false, }, { code3: 'eng', code2: 'en', name: 'English', - android: true, - ios: true, }, { code3: 'enm', code2: '', name: 'English, Middle (1100-1500)', - android: false, - ios: false, }, { code3: 'epo', code2: 'eo', name: 'Esperanto', - android: true, - ios: false, }, { code3: 'est', code2: 'et', name: 'Estonian', - android: true, - ios: false, }, { code3: 'eus', code2: 'eu', name: 'Basque', - android: false, - ios: false, }, { code3: 'ewe', code2: 'ee', name: 'Ewe', - android: false, - ios: false, }, { code3: 'ewo', code2: '', name: 'Ewondo', - android: false, - ios: false, }, { code3: 'fan', code2: '', name: 'Fang', - android: false, - ios: false, }, { code3: 'fao', code2: 'fo', name: 'Faroese', - android: false, - ios: false, }, { code3: 'fas', code2: 'fa', name: 'Persian', - android: true, - ios: false, }, { code3: 'fat', code2: '', name: 'Akan', - android: false, - ios: false, }, { code3: 'fij', code2: 'fj', name: 'Fijian', - android: false, - ios: false, }, { code3: 'fil', code2: '', name: 'Filipino', - android: false, - ios: false, }, { code3: 'fin', code2: 'fi', name: 'Finnish', - android: true, - ios: false, }, { code3: 'fiu', code2: '', name: 'Finno-Ugrian languages', - android: false, - ios: false, }, { code3: 'fon', code2: '', name: 'Fon', - android: false, - ios: false, }, { code3: 'fra', code2: 'fr', name: 'French', - android: true, - ios: true, }, { code3: 'fre', code2: 'fr', name: 'French', - android: true, - ios: true, }, { code3: 'frm', code2: '', name: 'French, Middle (ca.1400-1600)', - android: false, - ios: false, }, { code3: 'fro', code2: '', name: 'French, Old (842-ca.1400)', - android: false, - ios: false, }, { code3: 'frr', code2: '', name: 'Northern Frisian', - android: false, - ios: false, }, { code3: 'frs', code2: '', name: 'Eastern Frisian', - android: false, - ios: false, }, { code3: 'fry', code2: 'fy', name: 'Western Frisian', - android: false, - ios: false, }, { code3: 'ful', code2: 'ff', name: 'Fulah', - android: false, - ios: false, }, { code3: 'fur', code2: '', name: 'Friulian', - android: false, - ios: false, }, { code3: 'gaa', code2: '', name: 'Ga', - android: false, - ios: false, }, { code3: 'gay', code2: '', name: 'Gayo', - android: false, - ios: false, }, { code3: 'gba', code2: '', name: 'Gbaya', - android: false, - ios: false, }, { code3: 'gem', code2: '', name: 'Germanic languages', - android: false, - ios: false, }, { code3: 'geo', code2: 'ka', name: 'Georgian', - android: true, - ios: false, }, { code3: 'ger', code2: 'de', name: 'German', - android: true, - ios: true, }, { code3: 'gez', code2: '', name: 'Geez', - android: false, - ios: false, }, { code3: 'gil', code2: '', name: 'Gilbertese', - android: false, - ios: false, }, { code3: 'gla', code2: 'gd', name: 'Scottish Gaelic', - android: false, - ios: false, }, { code3: 'gle', code2: 'ga', name: 'Irish', - android: true, - ios: false, }, { code3: 'glg', code2: 'gl', name: 'Galician', - android: true, - ios: false, }, { code3: 'glv', code2: 'gv', name: 'Manx', - android: false, - ios: false, }, { code3: 'gmh', code2: '', name: 'German, Middle High (ca.1050-1500)', - android: false, - ios: false, }, { code3: 'goh', code2: '', name: 'German, Old High (ca.750-1050)', - android: false, - ios: false, }, { code3: 'gon', code2: '', name: 'Gondi', - android: false, - ios: false, }, { code3: 'gor', code2: '', name: 'Gorontalo', - android: false, - ios: false, }, { code3: 'got', code2: '', name: 'Gothic', - android: false, - ios: false, }, { code3: 'grb', code2: '', name: 'Grebo', - android: false, - ios: false, }, { code3: 'grc', code2: '', name: 'Ancient Greek', - android: false, - ios: false, }, { code3: 'gre', code2: 'el', name: 'Greek', - android: true, - ios: false, }, { code3: 'grn', code2: 'gn', name: 'Guarani', - android: false, - ios: false, }, { code3: 'gsw', code2: '', name: 'Swiss German; Alemannic; Alsatian', - android: false, - ios: false, }, { code3: 'guj', code2: 'gu', name: 'Gujarati', - android: true, - ios: false, }, { code3: 'gwi', code2: '', name: "Gwich'in", - android: false, - ios: false, }, { code3: 'hai', code2: '', name: 'Haida', - android: false, - ios: false, }, { code3: 'hat', code2: 'ht', name: 'Haitian Creole', - android: true, - ios: false, }, { code3: 'hau', code2: 'ha', name: 'Hausa', - android: false, - ios: false, }, { code3: 'haw', code2: '', name: 'Hawaiian', - android: false, - ios: false, }, { code3: 'heb', code2: 'he', name: 'Hebrew', - android: true, - ios: false, }, { code3: 'her', code2: 'hz', name: 'Herero', - android: false, - ios: false, }, { code3: 'hil', code2: '', name: 'Hiligaynon', - android: false, - ios: false, }, { code3: 'him', code2: '', name: 'Himachali languages; Western Pahari languages', - android: false, - ios: false, }, { code3: 'hin', code2: 'hi', name: 'Hindi', - android: true, - ios: true, }, { code3: 'hit', code2: '', name: 'Hittite', - android: false, - ios: false, }, { code3: 'hmn', code2: '', name: 'Hmong', - android: false, - ios: false, }, { code3: 'hmo', code2: 'ho', name: 'Hiri Motu', - android: false, - ios: false, }, { code3: 'hrv', code2: 'hr', name: 'Croatian', - android: true, - ios: false, }, { code3: 'hsb', code2: '', name: 'Upper Sorbian', - android: false, - ios: false, }, { code3: 'hun', code2: 'hu', name: 'Hungarian', - android: true, - ios: false, }, { code3: 'hup', code2: '', name: 'Hupa', - android: false, - ios: false, }, { code3: 'hye', code2: 'hy', name: 'Armenian', - android: false, - ios: false, }, { code3: 'iba', code2: '', name: 'Iban', - android: false, - ios: false, }, { code3: 'ibo', code2: 'ig', name: 'Igbo', - android: false, - ios: false, }, { code3: 'ice', code2: 'is', name: 'Icelandic', - android: true, - ios: false, }, { code3: 'ido', code2: 'io', name: 'Ido', - android: false, - ios: false, }, { code3: 'iii', code2: 'ii', name: 'Sichuan Yi; Nuosu', - android: false, - ios: false, }, { code3: 'ijo', code2: '', name: 'Ijo languages', - android: false, - ios: false, }, { code3: 'iku', code2: 'iu', name: 'Inuktitut', - android: false, - ios: false, }, { code3: 'ile', code2: 'ie', name: 'Interlingue', - android: false, - ios: false, }, { code3: 'ilo', code2: '', name: 'Iloko', - android: false, - ios: false, }, { code3: 'ina', code2: 'ia', name: 'Interlingua', - android: false, - ios: false, }, { code3: 'inc', code2: '', name: 'Indic languages', - android: false, - ios: false, }, { code3: 'ind', code2: 'id', name: 'Indonesian', - android: true, - ios: false, }, { code3: 'ine', code2: '', name: 'Indo-European languages', - android: false, - ios: false, }, { code3: 'inh', code2: '', name: 'Ingush', - android: false, - ios: false, }, { code3: 'ipk', code2: 'ik', name: 'Inupiaq', - android: false, - ios: false, }, { code3: 'ira', code2: '', name: 'Iranian languages', - android: false, - ios: false, }, { code3: 'iro', code2: '', name: 'Iroquoian languages', - android: false, - ios: false, }, { code3: 'isl', code2: 'is', name: 'Icelandic', - android: true, - ios: false, }, { code3: 'ita', code2: 'it', name: 'Italian', - android: true, - ios: true, }, { code3: 'jav', code2: 'jv', name: 'Javanese', - android: false, - ios: false, }, { code3: 'jbo', code2: '', name: 'Lojban', - android: false, - ios: false, }, { code3: 'jpn', code2: 'ja', name: 'Japanese', - android: true, - ios: true, }, { code3: 'jpr', code2: '', name: 'Judeo-Persian', - android: false, - ios: false, }, { code3: 'jrb', code2: '', name: 'Judeo-Arabic', - android: false, - ios: false, }, { code3: 'kaa', code2: '', name: 'Kara-Kalpak', - android: false, - ios: false, }, { code3: 'kab', code2: '', name: 'Kabyle', - android: false, - ios: false, }, { code3: 'kac', code2: '', name: 'Kachin; Jingpho', - android: false, - ios: false, }, { code3: 'kal', code2: 'kl', name: 'Kalaallisut', - android: false, - ios: false, }, { code3: 'kam', code2: '', name: 'Kamba', - android: false, - ios: false, }, { code3: 'kan', code2: 'kn', name: 'Kannada', - android: true, - ios: false, }, { code3: 'kar', code2: '', name: 'Karen languages', - android: false, - ios: false, }, { code3: 'kas', code2: 'ks', name: 'Kashmiri', - android: false, - ios: false, }, { code3: 'kat', code2: 'ka', name: 'Georgian', - android: true, - ios: false, }, { code3: 'kau', code2: 'kr', name: 'Kanuri', - android: false, - ios: false, }, { code3: 'kaw', code2: '', name: 'Kawi', - android: false, - ios: false, }, { code3: 'kaz', code2: 'kk', name: 'Kazakh', - android: false, - ios: false, }, { code3: 'kbd', code2: '', name: 'Kabardian', - android: false, - ios: false, }, { code3: 'kha', code2: '', name: 'Khasi', - android: false, - ios: false, }, { code3: 'khi', code2: '', name: 'Khoisan languages', - android: false, - ios: false, }, { code3: 'khm', code2: 'km', name: 'Khmer', - android: false, - ios: false, }, { code3: 'kho', code2: '', name: 'Khotanese; Sakan', - android: false, - ios: false, }, { code3: 'kik', code2: 'ki', name: 'Kikuyu; Gikuyu', - android: false, - ios: false, }, { code3: 'kin', code2: 'rw', name: 'Kinyarwanda', - android: false, - ios: false, }, { code3: 'kir', code2: 'ky', name: 'Kyrgyz', - android: false, - ios: false, }, { code3: 'kmb', code2: '', name: 'Kimbundu', - android: false, - ios: false, }, { code3: 'kok', code2: '', name: 'Konkani', - android: false, - ios: false, }, { code3: 'kom', code2: 'kv', name: 'Komi', - android: false, - ios: false, }, { code3: 'kon', code2: 'kg', name: 'Kongo', - android: false, - ios: false, }, { code3: 'kor', code2: 'ko', name: 'Korean', - android: true, - ios: true, }, { code3: 'kos', code2: '', name: 'Kosraean', - android: false, - ios: false, }, { code3: 'kpe', code2: '', name: 'Kpelle', - android: false, - ios: false, }, { code3: 'krc', code2: '', name: 'Karachay-Balkar', - android: false, - ios: false, }, { code3: 'krl', code2: '', name: 'Karelian', - android: false, - ios: false, }, { code3: 'kro', code2: '', name: 'Kru languages', - android: false, - ios: false, }, { code3: 'kru', code2: '', name: 'Kurukh', - android: false, - ios: false, }, { code3: 'kua', code2: 'kj', name: 'Kuanyama; Kwanyama', - android: false, - ios: false, }, { code3: 'kum', code2: '', name: 'Kumyk', - android: false, - ios: false, }, { code3: 'kur', code2: 'ku', name: 'Kurdish', - android: false, - ios: false, }, { code3: 'kut', code2: '', name: 'Kutenai', - android: false, - ios: false, }, { code3: 'lad', code2: '', name: 'Ladino', - android: false, - ios: false, }, { code3: 'lah', code2: '', name: 'Lahnda', - android: false, - ios: false, }, { code3: 'lam', code2: '', name: 'Lamba', - android: false, - ios: false, }, { code3: 'lao', code2: 'lo', name: 'Lao', - android: false, - ios: false, }, { code3: 'lat', code2: 'la', name: 'Latin', - android: false, - ios: false, }, { code3: 'lav', code2: 'lv', name: 'Latvian', - android: true, - ios: false, }, { code3: 'lez', code2: '', name: 'Lezghian', - android: false, - ios: false, }, { code3: 'lim', code2: 'li', name: 'Limburgish', - android: false, - ios: false, }, { code3: 'lin', code2: 'ln', name: 'Lingala', - android: false, - ios: false, }, { code3: 'lit', code2: 'lt', name: 'Lithuanian', - android: true, - ios: false, }, { code3: 'lol', code2: '', name: 'Mongo', - android: false, - ios: false, }, { code3: 'loz', code2: '', name: 'Lozi', - android: false, - ios: false, }, { code3: 'ltz', code2: 'lb', name: 'Luxembourgish', - android: false, - ios: false, }, { code3: 'lua', code2: '', name: 'Luba-Lulua', - android: false, - ios: false, }, { code3: 'lub', code2: 'lu', name: 'Luba-Katanga', - android: false, - ios: false, }, { code3: 'lug', code2: 'lg', name: 'Ganda', - android: false, - ios: false, }, { code3: 'lui', code2: '', name: 'Luiseno', - android: false, - ios: false, }, { code3: 'lun', code2: '', name: 'Lunda', - android: false, - ios: false, }, { code3: 'luo', code2: '', name: 'Luo (Kenya and Tanzania)', - android: false, - ios: false, }, { code3: 'lus', code2: '', name: 'Mizo', - android: false, - ios: false, }, { code3: 'mac', code2: 'mk', name: 'Macedonian', - android: true, - ios: false, }, { code3: 'mad', code2: '', name: 'Madurese', - android: false, - ios: false, }, { code3: 'mag', code2: '', name: 'Magahi', - android: false, - ios: false, }, { code3: 'mah', code2: 'mh', name: 'Marshallese', - android: false, - ios: false, }, { code3: 'mai', code2: '', name: 'Maithili', - android: false, - ios: false, }, { code3: 'mak', code2: '', name: 'Makasar', - android: false, - ios: false, }, { code3: 'mal', code2: 'ml', name: 'Malayalam', - android: false, - ios: false, }, { code3: 'man', code2: '', name: 'Mandingo', - android: false, - ios: false, }, { code3: 'mao', code2: 'mi', name: 'Māori', - android: false, - ios: false, }, { code3: 'map', code2: '', name: 'Austronesian languages', - android: false, - ios: false, }, { code3: 'mar', code2: 'mr', name: 'Marathi', - android: true, - ios: false, }, { code3: 'mas', code2: '', name: 'Masai', - android: false, - ios: false, }, { code3: 'may', code2: 'ms', name: 'Malay', - android: true, - ios: false, }, { code3: 'mdf', code2: '', name: 'Moksha', - android: false, - ios: false, }, { code3: 'mdr', code2: '', name: 'Mandar', - android: false, - ios: false, }, { code3: 'men', code2: '', name: 'Mende', - android: false, - ios: false, }, { code3: 'mga', code2: '', name: 'Irish, Middle (900-1200)', - android: false, - ios: false, }, { code3: 'mic', code2: '', name: "Mi'kmaq; Micmac", - android: false, - ios: false, }, { code3: 'min', code2: '', name: 'Minangkabau', - android: false, - ios: false, }, { code3: 'mis', code2: '', name: 'Uncoded languages', - android: false, - ios: false, }, { code3: 'mkd', code2: 'mk', name: 'Macedonian', - android: true, - ios: false, }, { code3: 'mkh', code2: '', name: 'Mon-Khmer languages', - android: false, - ios: false, }, { code3: 'mlg', code2: 'mg', name: 'Malagasy', - android: false, - ios: false, }, { code3: 'mlt', code2: 'mt', name: 'Maltese', - android: true, - ios: false, }, { code3: 'mnc', code2: '', name: 'Manchu', - android: false, - ios: false, }, { code3: 'mni', code2: '', name: 'Manipuri', - android: false, - ios: false, }, { code3: 'mno', code2: '', name: 'Manobo languages', - android: false, - ios: false, }, { code3: 'moh', code2: '', name: 'Mohawk', - android: false, - ios: false, }, { code3: 'mon', code2: 'mn', name: 'Mongolian', - android: false, - ios: false, }, { code3: 'mos', code2: '', name: 'Mossi', - android: false, - ios: false, }, { code3: 'mri', code2: 'mi', name: 'Māori', - android: false, - ios: false, }, { code3: 'msa', code2: 'ms', name: 'Malay', - android: true, - ios: false, }, { code3: 'mul', code2: '', name: 'Multiple languages', - android: false, - ios: false, }, { code3: 'mun', code2: '', name: 'Munda languages', - android: false, - ios: false, }, { code3: 'mus', code2: '', name: 'Creek', - android: false, - ios: false, }, { code3: 'mwl', code2: '', name: 'Mirandese', - android: false, - ios: false, }, { code3: 'mwr', code2: '', name: 'Marwari', - android: false, - ios: false, }, { code3: 'mya', code2: 'my', name: 'Burmese', - android: false, - ios: false, }, { code3: 'myn', code2: '', name: 'Mayan languages', - android: false, - ios: false, }, { code3: 'myv', code2: '', name: 'Erzya', - android: false, - ios: false, }, { code3: 'nah', code2: '', name: 'Nahuatl languages', - android: false, - ios: false, }, { code3: 'nai', code2: '', name: 'North American Indian languages', - android: false, - ios: false, }, { code3: 'nap', code2: '', name: 'Neapolitan', - android: false, - ios: false, }, { code3: 'nau', code2: 'na', name: 'Nauru', - android: false, - ios: false, }, { code3: 'nav', code2: 'nv', name: 'Navajo', - android: false, - ios: false, }, { code3: 'nbl', code2: 'nr', name: 'South Ndebele', - android: false, - ios: false, }, { code3: 'nde', code2: 'nd', name: 'North Ndebele', - android: false, - ios: false, }, { code3: 'ndo', code2: 'ng', name: 'Ndonga', - android: false, - ios: false, }, { code3: 'nds', code2: '', name: 'Low German; Low Saxon; German, Low; Saxon, Low', - android: false, - ios: false, }, { code3: 'nep', code2: 'ne', name: 'Nepali', - android: false, - ios: false, }, { code3: 'new', code2: '', name: 'Nepal Bhasa; Newari', - android: false, - ios: false, }, { code3: 'nia', code2: '', name: 'Nias', - android: false, - ios: false, }, { code3: 'nic', code2: '', name: 'Niger-Kordofanian languages', - android: false, - ios: false, }, { code3: 'niu', code2: '', name: 'Niuean', - android: false, - ios: false, }, { code3: 'nld', code2: 'nl', name: 'Dutch', - android: true, - ios: true, }, { code3: 'nno', code2: 'nn', name: 'Norwegian Nynorsk', - android: false, - ios: false, }, { code3: 'nob', code2: 'nb', name: 'Norwegian Bokmål', - android: false, - ios: false, }, { code3: 'nog', code2: '', name: 'Nogai', - android: false, - ios: false, }, { code3: 'non', code2: '', name: 'Norse, Old', - android: false, - ios: false, }, { code3: 'nor', code2: 'no', name: 'Norwegian', - android: true, - ios: false, }, { code3: 'nqo', code2: '', name: "N'Ko", - android: false, - ios: false, }, { code3: 'nso', code2: '', name: 'Northern Sotho', - android: false, - ios: false, }, { code3: 'nub', code2: '', name: 'Nubian languages', - android: false, - ios: false, }, { code3: 'nwc', code2: '', name: 'Classical Newari; Old Newari; Classical Nepal Bhasa', - android: false, - ios: false, }, { code3: 'nya', code2: 'ny', name: 'Nyanja', - android: false, - ios: false, }, { code3: 'nym', code2: '', name: 'Nyamwezi', - android: false, - ios: false, }, { code3: 'nyn', code2: '', name: 'Nyankole', - android: false, - ios: false, }, { code3: 'nyo', code2: '', name: 'Nyoro', - android: false, - ios: false, }, { code3: 'nzi', code2: '', name: 'Nzima', - android: false, - ios: false, }, { code3: 'oci', code2: 'oc', name: 'Occitan', - android: false, - ios: false, }, { code3: 'oji', code2: 'oj', name: 'Ojibwa', - android: false, - ios: false, }, { code3: 'ori', code2: 'or', name: 'Odia', - android: false, - ios: false, }, { code3: 'orm', code2: 'om', name: 'Oromo', - android: false, - ios: false, }, { code3: 'osa', code2: '', name: 'Osage', - android: false, - ios: false, }, { code3: 'oss', code2: 'os', name: 'Ossetic', - android: false, - ios: false, }, { code3: 'ota', code2: '', name: 'Turkish, Ottoman (1500-1928)', - android: false, - ios: false, }, { code3: 'oto', code2: '', name: 'Otomian languages', - android: false, - ios: false, }, { code3: 'paa', code2: '', name: 'Papuan languages', - android: false, - ios: false, }, { code3: 'pag', code2: '', name: 'Pangasinan', - android: false, - ios: false, }, { code3: 'pal', code2: '', name: 'Pahlavi', - android: false, - ios: false, }, { code3: 'pam', code2: '', name: 'Pampanga; Kapampangan', - android: false, - ios: false, }, { code3: 'pan', code2: 'pa', name: 'Punjabi', - android: false, - ios: false, }, { code3: 'pap', code2: '', name: 'Papiamento', - android: false, - ios: false, }, { code3: 'pau', code2: '', name: 'Palauan', - android: false, - ios: false, }, { code3: 'peo', code2: '', name: 'Persian, Old (ca.600-400 B.C.)', - android: false, - ios: false, }, { code3: 'per', code2: 'fa', name: 'Persian', - android: true, - ios: false, }, { code3: 'phi', code2: '', name: 'Philippine languages', - android: false, - ios: false, }, { code3: 'phn', code2: '', name: 'Phoenician', - android: false, - ios: false, }, { code3: 'pli', code2: 'pi', name: 'Pali', - android: false, - ios: false, }, { code3: 'pol', code2: 'pl', name: 'Polish', - android: true, - ios: true, }, { code3: 'pon', code2: '', name: 'Pohnpeian', - android: false, - ios: false, }, { code3: 'por', code2: 'pt', name: 'Portuguese', - android: true, - ios: true, }, { code3: 'pra', code2: '', name: 'Prakrit languages', - android: false, - ios: false, }, { code3: 'pro', code2: '', name: 'Provençal, Old (to 1500);Occitan, Old (to 1500)', - android: false, - ios: false, }, { code3: 'pus', code2: 'ps', name: 'Pashto', - android: false, - ios: false, }, { code3: 'que', code2: 'qu', name: 'Quechua', - android: false, - ios: false, }, { code3: 'raj', code2: '', name: 'Rajasthani', - android: false, - ios: false, }, { code3: 'rap', code2: '', name: 'Rapanui', - android: false, - ios: false, }, { code3: 'rar', code2: '', name: 'Rarotongan; Cook Islands Maori', - android: false, - ios: false, }, { code3: 'roa', code2: '', name: 'Romance languages', - android: false, - ios: false, }, { code3: 'roh', code2: 'rm', name: 'Romansh', - android: false, - ios: false, }, { code3: 'rom', code2: '', name: 'Romany', - android: false, - ios: false, }, { code3: 'rum', code2: 'ro', name: 'Romanian', - android: true, - ios: false, }, { code3: 'ron', code2: 'ro', name: 'Romanian', - android: true, - ios: false, }, { code3: 'run', code2: 'rn', name: 'Rundi', - android: false, - ios: false, }, { code3: 'rup', code2: '', name: 'Aromanian; Arumanian; Macedo-Romanian', - android: false, - ios: false, }, { code3: 'rus', code2: 'ru', name: 'Russian', - android: true, - ios: true, }, { code3: 'sad', code2: '', name: 'Sandawe', - android: false, - ios: false, }, { code3: 'sag', code2: 'sg', name: 'Sango', - android: false, - ios: false, }, { code3: 'sah', code2: '', name: 'Yakut', - android: false, - ios: false, }, { code3: 'sai', code2: '', name: 'South American Indian languages', - android: false, - ios: false, }, { code3: 'sal', code2: '', name: 'Salishan languages', - android: false, - ios: false, }, { code3: 'sam', code2: '', name: 'Samaritan Aramaic', - android: false, - ios: false, }, { code3: 'san', code2: 'sa', name: 'Sanskrit', - android: false, - ios: false, }, { code3: 'sas', code2: '', name: 'Sasak', - android: false, - ios: false, }, { code3: 'sat', code2: '', name: 'Santali', - android: false, - ios: false, }, { code3: 'scn', code2: '', name: 'Sicilian', - android: false, - ios: false, }, { code3: 'sco', code2: '', name: 'Scots', - android: false, - ios: false, }, { code3: 'sel', code2: '', name: 'Selkup', - android: false, - ios: false, }, { code3: 'sem', code2: '', name: 'Semitic languages', - android: false, - ios: false, }, { code3: 'sga', code2: '', name: 'Irish, Old (to 900)', - android: false, - ios: false, }, { code3: 'sgn', code2: '', name: 'Sign Languages', - android: false, - ios: false, }, { code3: 'shn', code2: '', name: 'Shan', - android: false, - ios: false, }, { code3: 'sid', code2: '', name: 'Sidamo', - android: false, - ios: false, }, { code3: 'sin', code2: 'si', name: 'Sinhala', - android: false, - ios: false, }, { code3: 'sio', code2: '', name: 'Siouan languages', - android: false, - ios: false, }, { code3: 'sit', code2: '', name: 'Sino-Tibetan languages', - android: false, - ios: false, }, { code3: 'sla', code2: '', name: 'Slavic languages', - android: false, - ios: false, }, { code3: 'slo', code2: 'sk', name: 'Slovak', - android: true, - ios: false, }, { code3: 'slk', code2: 'sk', name: 'Slovak', - android: true, - ios: false, }, { code3: 'slv', code2: 'sl', name: 'Slovenian', - android: true, - ios: false, }, { code3: 'sma', code2: '', name: 'Southern Sami', - android: false, - ios: false, }, { code3: 'sme', code2: 'se', name: 'Northern Sami', - android: false, - ios: false, }, { code3: 'smi', code2: '', name: 'Sami languages', - android: false, - ios: false, }, { code3: 'smj', code2: '', name: 'Lule Sami', - android: false, - ios: false, }, { code3: 'smn', code2: '', name: 'Inari Sami', - android: false, - ios: false, }, { code3: 'smo', code2: 'sm', name: 'Samoan', - android: false, - ios: false, }, { code3: 'sms', code2: '', name: 'Skolt Sami', - android: false, - ios: false, }, { code3: 'sna', code2: 'sn', name: 'Shona', - android: false, - ios: false, }, { code3: 'snd', code2: 'sd', name: 'Sindhi', - android: false, - ios: false, }, { code3: 'snk', code2: '', name: 'Soninke', - android: false, - ios: false, }, { code3: 'sog', code2: '', name: 'Sogdian', - android: false, - ios: false, }, { code3: 'som', code2: 'so', name: 'Somali', - android: false, - ios: false, }, { code3: 'son', code2: '', name: 'Songhai languages', - android: false, - ios: false, }, { code3: 'sot', code2: 'st', name: 'Southern Sotho', - android: false, - ios: false, }, { code3: 'spa', code2: 'es', name: 'Spanish', - android: true, - ios: true, }, { code3: 'sqi', code2: 'sq', name: 'Albanian', - android: true, - ios: false, }, { code3: 'srd', code2: 'sc', name: 'Sardinian', - android: false, - ios: false, }, { code3: 'srn', code2: '', name: 'Sranan Tongo', - android: false, - ios: false, }, { code3: 'srp', code2: 'sr', name: 'Serbian', - android: false, - ios: false, }, { code3: 'srr', code2: '', name: 'Serer', - android: false, - ios: false, }, { code3: 'ssa', code2: '', name: 'Nilo-Saharan languages', - android: false, - ios: false, }, { code3: 'ssw', code2: 'ss', name: 'Swati', - android: false, - ios: false, }, { code3: 'suk', code2: '', name: 'Sukuma', - android: false, - ios: false, }, { code3: 'sun', code2: 'su', name: 'Sundanese', - android: false, - ios: false, }, { code3: 'sus', code2: '', name: 'Susu', - android: false, - ios: false, }, { code3: 'sux', code2: '', name: 'Sumerian', - android: false, - ios: false, }, { code3: 'swa', code2: 'sw', name: 'Swahili', - android: true, - ios: false, }, { code3: 'swe', code2: 'sv', name: 'Swedish', - android: true, - ios: false, }, { code3: 'syc', code2: '', name: 'Classical Syriac', - android: false, - ios: false, }, { code3: 'syr', code2: '', name: 'Syriac', - android: false, - ios: false, }, { code3: 'tah', code2: 'ty', name: 'Tahitian', - android: false, - ios: false, }, { code3: 'tai', code2: '', name: 'Tai languages', - android: false, - ios: false, }, { code3: 'tam', code2: 'ta', name: 'Tamil', - android: true, - ios: false, }, { code3: 'tat', code2: 'tt', name: 'Tatar', - android: false, - ios: false, }, { code3: 'tel', code2: 'te', name: 'Telugu', - android: true, - ios: false, }, { code3: 'tem', code2: '', name: 'Timne', - android: false, - ios: false, }, { code3: 'ter', code2: '', name: 'Tereno', - android: false, - ios: false, }, { code3: 'tet', code2: '', name: 'Tetum', - android: false, - ios: false, }, { code3: 'tgk', code2: 'tg', name: 'Tajik', - android: false, - ios: false, }, { code3: 'tgl', code2: 'tl', name: 'Filipino', - android: true, - ios: false, }, { code3: 'tha', code2: 'th', name: 'Thai', - android: true, - ios: true, }, { code3: 'tib', code2: 'bo', name: 'Tibetan', - android: false, - ios: false, }, { code3: 'tig', code2: '', name: 'Tigre', - android: false, - ios: false, }, { code3: 'tir', code2: 'ti', name: 'Tigrinya', - android: false, - ios: false, }, { code3: 'tiv', code2: '', name: 'Tiv', - android: false, - ios: false, }, { code3: 'tkl', code2: '', name: 'Tokelau', - android: false, - ios: false, }, { code3: 'tlh', code2: '', name: 'Klingon; tlhIngan-Hol', - android: false, - ios: false, }, { code3: 'tli', code2: '', name: 'Tlingit', - android: false, - ios: false, }, { code3: 'tmh', code2: '', name: 'Tamashek', - android: false, - ios: false, }, { code3: 'tog', code2: '', name: 'Tonga (Nyasa)', - android: false, - ios: false, }, { code3: 'ton', code2: 'to', name: 'Tongan', - android: false, - ios: false, }, { code3: 'tpi', code2: '', name: 'Tok Pisin', - android: false, - ios: false, }, { code3: 'tsi', code2: '', name: 'Tsimshian', - android: false, - ios: false, }, { code3: 'tsn', code2: 'tn', name: 'Tswana', - android: false, - ios: false, }, { code3: 'tso', code2: 'ts', name: 'Tsonga', - android: false, - ios: false, }, { code3: 'tuk', code2: 'tk', name: 'Turkmen', - android: false, - ios: false, }, { code3: 'tum', code2: '', name: 'Tumbuka', - android: false, - ios: false, }, { code3: 'tup', code2: '', name: 'Tupi languages', - android: false, - ios: false, }, { code3: 'tur', code2: 'tr', name: 'Turkish', - android: true, - ios: true, }, { code3: 'tut', code2: '', name: 'Altaic languages', - android: false, - ios: false, }, { code3: 'tvl', code2: '', name: 'Tuvalu', - android: false, - ios: false, }, { code3: 'twi', code2: 'tw', name: 'Akan', - android: false, - ios: false, }, { code3: 'tyv', code2: '', name: 'Tuvinian', - android: false, - ios: false, }, { code3: 'udm', code2: '', name: 'Udmurt', - android: false, - ios: false, }, { code3: 'uga', code2: '', name: 'Ugaritic', - android: false, - ios: false, }, { code3: 'uig', code2: 'ug', name: 'Uyghur', - android: false, - ios: false, }, { code3: 'ukr', code2: 'uk', name: 'Ukrainian', - android: true, - ios: true, }, { code3: 'umb', code2: '', name: 'Umbundu', - android: false, - ios: false, }, { code3: 'und', code2: '', name: 'Undetermined', - android: false, - ios: false, }, { code3: 'urd', code2: 'ur', name: 'Urdu', - android: true, - ios: false, }, { code3: 'uzb', code2: 'uz', name: 'Uzbek', - android: false, - ios: false, }, { code3: 'vai', code2: '', name: 'Vai', - android: false, - ios: false, }, { code3: 'ven', code2: 've', name: 'Venda', - android: false, - ios: false, }, { code3: 'vie', code2: 'vi', name: 'Vietnamese', - android: true, - ios: true, }, { code3: 'vol', code2: 'vo', name: 'Volapük', - android: false, - ios: false, }, { code3: 'vot', code2: '', name: 'Votic', - android: false, - ios: false, }, { code3: 'wak', code2: '', name: 'Wakashan languages', - android: false, - ios: false, }, { code3: 'wal', code2: '', name: 'Wolaitta; Wolaytta', - android: false, - ios: false, }, { code3: 'war', code2: '', name: 'Waray', - android: false, - ios: false, }, { code3: 'was', code2: '', name: 'Washo', - android: false, - ios: false, }, { code3: 'wel', code2: 'cy', name: 'Welsh', - android: true, - ios: false, }, { code3: 'wen', code2: '', name: 'Sorbian languages', - android: false, - ios: false, }, { code3: 'wln', code2: 'wa', name: 'Walloon', - android: false, - ios: false, }, { code3: 'wol', code2: 'wo', name: 'Wolof', - android: false, - ios: false, }, { code3: 'xal', code2: '', name: 'Kalmyk; Oirat', - android: false, - ios: false, }, { code3: 'xho', code2: 'xh', name: 'Xhosa', - android: false, - ios: false, }, { code3: 'yao', code2: '', name: 'Yao', - android: false, - ios: false, }, { code3: 'yap', code2: '', name: 'Yapese', - android: false, - ios: false, }, { code3: 'yid', code2: 'yi', name: 'Yiddish', - android: false, - ios: false, }, { code3: 'yor', code2: 'yo', name: 'Yoruba', - android: false, - ios: false, }, { code3: 'ypk', code2: '', name: 'Yupik languages', - android: false, - ios: false, }, { code3: 'zap', code2: '', name: 'Zapotec', - android: false, - ios: false, }, { code3: 'zbl', code2: '', name: 'Blissymbols; Blissymbolics; Bliss', - android: false, - ios: false, }, { code3: 'zen', code2: '', name: 'Zenaga', - android: false, - ios: false, }, { code3: 'zgh', code2: '', name: 'Standard Moroccan Tamazight', - android: false, - ios: false, }, { code3: 'zha', code2: 'za', name: 'Zhuang; Chuang', - android: false, - ios: false, }, { code3: 'zho', code2: 'zh', name: 'Chinese', - android: true, - ios: true, }, { code3: 'znd', code2: '', name: 'Zande languages', - android: false, - ios: false, }, { code3: 'zul', code2: 'zu', name: 'Zulu', - android: false, - ios: false, }, { code3: 'zun', code2: '', name: 'Zuni', - android: false, - ios: false, }, { code3: 'zza', code2: '', name: 'Zaza; Dimili; Dimli; Kirdki; Kirmanjki; Zazaki', - android: false, - ios: false, }, ] diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 38a339f54..1531784ae 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -1,5 +1,5 @@ import {memo, useCallback, useMemo} from 'react' -import {type GestureResponderEvent, Text as RNText, View} from 'react-native' +import {Text as RNText, View} from 'react-native' import { AppBskyFeedDefs, AppBskyFeedPost, @@ -14,11 +14,6 @@ import {makeProfileLink} from '#/lib/routes/links' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {niceDate} from '#/lib/strings/time' -import { - getPostLanguage, - getTranslatorLink, - isPostInLanguage, -} from '#/locale/helpers' import { POST_TOMBSTONE, type Shadow, @@ -26,7 +21,6 @@ import { } from '#/state/cache/post-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow' import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {useLanguagePrefs} from '#/state/preferences' import {type ThreadItem} from '#/state/queries/usePostThread/types' import {useSession} from '#/state/session' import {type OnPostSuccessData} from '#/state/shell/composer' @@ -44,8 +38,7 @@ import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' -import {InlineLinkText, Link} from '#/components/Link' -import {Loader} from '#/components/Loader' +import {Link} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -63,10 +56,6 @@ import {VerificationCheckButton} from '#/components/verification/VerificationChe import {WhoCanReply} from '#/components/WhoCanReply' import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' -import { - Provider as TranslateOnDeviceProvider, - useTranslateOnDevice, -} from '#/translation' import * as bsky from '#/types/bsky' export function ThreadItemAnchor({ @@ -89,18 +78,16 @@ export function ThreadItemAnchor({ } return ( - - - + ) } @@ -420,8 +407,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ shouldProxyLinks={true} /> ) : undefined} - - + {post.embed && ( ['value']['post'] -}) { - const t = useTheme() - const ax = useAnalytics() - const {t: l} = useLingui() - const langPrefs = useLanguagePrefs() - - const {translate, clearTranslation, translationState} = useTranslateOnDevice() - - const needsTranslation = useMemo( - () => - Boolean( - langPrefs.primaryLanguage && - !isPostInLanguage(post, [langPrefs.primaryLanguage]), - ), - [post, langPrefs.primaryLanguage], - ) - - const sourceLanguage = getPostLanguage(post) - - const onTranslatePress = useCallback( - (e: GestureResponderEvent) => { - e.preventDefault() - void translate( - post.record.text || '', - langPrefs.primaryLanguage, - sourceLanguage, - ) - - if ( - bsky.dangerousIsType( - post.record, - AppBskyFeedPost.isRecord, - ) - ) { - ax.metric('translate', { - sourceLanguages: post.record.langs ?? [], - targetLanguage: langPrefs.primaryLanguage, - textLength: post.record.text.length, - }) - } - - return false - }, - [ax, sourceLanguage, translate, langPrefs, post], - ) - - const onHideTranslation = useCallback( - (e: GestureResponderEvent) => { - e.preventDefault() - clearTranslation() - return false - }, - [clearTranslation], - ) - - return ( - needsTranslation && ( - - {translationState.status === 'loading' ? ( - - - - Translating… - - - ) : translationState.status === 'success' ? ( - - Hide translation - - ) : ( - - Translate - - )} - - ) - ) -} - function ExpandedPostDetails({ post, isThreadAuthor, diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index c4ff88def..6829772e4 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -38,6 +38,7 @@ import {PostHider} from '#/components/moderation/PostHider' import {type AppModerationCause} from '#/components/Pills' import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' import {ShowMoreTextButton} from '#/components/Post/ShowMoreTextButton' +import {TranslatedPost} from '#/components/Post/Translated' import {PostControls, PostControlsSkeleton} from '#/components/PostControls' import {RichText} from '#/components/RichText' import * as Skele from '#/components/Skeleton' @@ -320,6 +321,11 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ )} ) : undefined} + {post.embed && ( ) : null} + {post.embed && ( { if (hasNextPage && !isFetchingNextPage) { - fetchNextPage() + void fetchNextPage() } }} showsVerticalScrollIndicator={false} @@ -515,6 +513,7 @@ let VideoItem = ({ } } }, [ + ax, active, post.uri, post.author.did, @@ -621,7 +620,7 @@ function ModerationOverlay({ embed: AppBskyEmbedVideo.View onPressShow: () => void }) { - const {_} = useLingui() + const {t: l} = useLingui() const hider = Hider.useHider() const {bottom} = useSafeAreaInsets() @@ -648,7 +647,7 @@ function ModerationOverlay({ Hidden by your moderation settings.