diff --git a/package.json b/package.json index 5dc5ef96a..9aa41e942 100644 --- a/package.json +++ b/package.json @@ -146,7 +146,7 @@ "expo-image": "^2.4.0", "expo-image-crop-tool": "^0.1.8", "expo-image-manipulator": "~13.1.7", - "expo-image-picker": "^17.0.2", + "expo-image-picker": "17.0.5", "expo-intent-launcher": "^12.1.5", "expo-linear-gradient": "~14.1.5", "expo-linking": "~7.1.5", diff --git a/yarn.lock b/yarn.lock index fa715c894..20020f85d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11339,10 +11339,10 @@ expo-image-manipulator@~13.1.7: dependencies: expo-image-loader "~5.1.0" -expo-image-picker@^17.0.2: - version "17.0.2" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.2.tgz#79af7192b2947e54686d0ece6ccbb5f6a178a809" - integrity sha512-O74FIrc37KB4ZxC/BMUL3fEZwdmIB60As0q5XczRlzPvWismBl7GG3pPy+o5SGUI2jcepTvQAa2PcNcMbUZNYg== +expo-image-picker@17.0.5: + version "17.0.5" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-17.0.5.tgz#236607a90f2c8ae63d377405d2bf295cfb192b84" + integrity sha512-K/fWkhNdZOPKKxjH6/+gnmqqOavBZt5lD5LI5BhdcCeTHt91GuBcTm5PM5AvR3eR0Vi5ZZqIKBTMVss8jLIoWg== dependencies: expo-image-loader "~6.0.0" -- 2.51.2 From 27c105856868da9c25a0e8732ff625a602967287 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 29 Aug 2025 01:18:56 +0300 Subject: [PATCH 2/5] use Link instead of manual navigation (#8930) --- src/components/FeedInterstitials.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/FeedInterstitials.tsx b/src/components/FeedInterstitials.tsx index 7debbf5e1..6278449a0 100644 --- a/src/components/FeedInterstitials.tsx +++ b/src/components/FeedInterstitials.tsx @@ -29,7 +29,7 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import * as FeedCard from '#/components/FeedCard' import {ArrowRight_Stroke2_Corner0_Rounded as ArrowRight} from '#/components/icons/Arrow' import {Hashtag_Stroke2_Corner0_Rounded as Hashtag} from '#/components/icons/Hashtag' -import {InlineLinkText} from '#/components/Link' +import {InlineLinkText, Link} from '#/components/Link' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import type * as bsky from '#/types/bsky' @@ -423,28 +423,28 @@ export function ProfileGrid({ function SeeMoreSuggestedProfilesCard() { const t = useTheme() const {_} = useLingui() - const navigation = useNavigation() return ( - + ) } -- 2.51.2 From 8a4398608acac5f44e8d3c0ea8f6976b8ae1119a Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 29 Aug 2025 03:03:12 +0300 Subject: [PATCH 3/5] Close web mention suggestions popup on `Escape` (#8605) * alf web typeahead * fix type error * fix escape behaviour * change selection on hover * rm React. * undo random change --- src/view/com/composer/Composer.tsx | 10 +- .../com/composer/text-input/TextInput.tsx | 57 +--- .../composer/text-input/TextInput.types.ts | 42 +++ .../com/composer/text-input/TextInput.web.tsx | 104 +++--- .../composer/text-input/web/Autocomplete.tsx | 313 ++++++++---------- 5 files changed, 253 insertions(+), 273 deletions(-) create mode 100644 src/view/com/composer/text-input/TextInput.types.ts diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b533510ec..20f2549ad 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -114,10 +114,7 @@ import {SelectPostLanguagesBtn} from '#/view/com/composer/select-language/Select import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' // TODO: Prevent naming components that coincide with RN primitives // due to linting false positives -import { - TextInput, - type TextInputRef, -} from '#/view/com/composer/text-input/TextInput' +import {TextInput} from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' @@ -155,6 +152,7 @@ import { processVideo, type VideoState, } from './state/video' +import {type TextInputRef} from './text-input/TextInput.types' import {getVideoMetadata} from './videos/pickVideo' import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop' @@ -306,7 +304,9 @@ export const ComposePost = ({ ) const onPressCancel = useCallback(() => { - if ( + if (textInput.current?.maybeClosePopup()) { + return + } else if ( thread.posts.some( post => post.shortenedGraphemeLength > 0 || diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index ea92d0b91..8b3e61b0e 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,7 +1,6 @@ -import React, { - type ComponentProps, - forwardRef, +import { useCallback, + useImperativeHandle, useMemo, useRef, useState, @@ -9,7 +8,6 @@ import React, { import { type NativeSyntheticEvent, Text as RNText, - type TextInput as RNTextInput, type TextInputSelectionChangeEventData, View, } from 'react-native' @@ -33,57 +31,38 @@ import { import {atoms as a, useAlf} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {Autocomplete} from './mobile/Autocomplete' - -export interface TextInputRef { - focus: () => void - blur: () => void - getCursorPosition: () => DOMRect | undefined -} - -interface TextInputProps extends ComponentProps { - richtext: RichText - placeholder: string - webForceMinHeight: boolean - hasRightPadding: boolean - isActive: boolean - setRichText: (v: RichText) => void - onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => void - onNewLink: (uri: string) => void - onError: (err: string) => void -} +import {type TextInputProps} from './TextInput.types' interface Selection { start: number end: number } -export const TextInput = forwardRef(function TextInputImpl( - { - richtext, - placeholder, - hasRightPadding, - setRichText, - onPhotoPasted, - onNewLink, - onError, - ...props - }: TextInputProps, +export function TextInput({ ref, -) { + richtext, + placeholder, + hasRightPadding, + setRichText, + onPhotoPasted, + onNewLink, + onError, + ...props +}: TextInputProps) { const {theme: t, fonts} = useAlf() const textInput = useRef(null) const textInputSelection = useRef({start: 0, end: 0}) const theme = useTheme() const [autocompletePrefix, setAutocompletePrefix] = useState('') - const prevLength = React.useRef(richtext.length) + const prevLength = useRef(richtext.length) - React.useImperativeHandle(ref, () => ({ + useImperativeHandle(ref, () => ({ focus: () => textInput.current?.focus(), blur: () => { textInput.current?.blur() }, getCursorPosition: () => undefined, // Not implemented on native + maybeClosePopup: () => false, // Not needed on native })) const pastSuggestedUris = useRef(new Set()) @@ -185,7 +164,7 @@ export const TextInput = forwardRef(function TextInputImpl( [onChangeText, richtext, setAutocompletePrefix], ) - const inputTextStyle = React.useMemo(() => { + const inputTextStyle = useMemo(() => { const style = normalizeTextStyles( [a.text_lg, a.leading_snug, t.atoms.text], { @@ -277,4 +256,4 @@ export const TextInput = forwardRef(function TextInputImpl( /> ) -}) +} diff --git a/src/view/com/composer/text-input/TextInput.types.ts b/src/view/com/composer/text-input/TextInput.types.ts new file mode 100644 index 000000000..fab2bc32f --- /dev/null +++ b/src/view/com/composer/text-input/TextInput.types.ts @@ -0,0 +1,42 @@ +import {type TextInput} from 'react-native' +import {type RichText} from '@atproto/api' + +export type TextInputRef = { + focus: () => void + blur: () => void + /** + * @platform web + */ + getCursorPosition: () => + | {left: number; right: number; top: number; bottom: number} + | undefined + /** + * Closes the autocomplete popup if it is open. + * Returns `true` if the popup was closed, `false` otherwise. + * + * @platform web + */ + maybeClosePopup: () => boolean +} + +export type TextInputProps = { + ref: React.Ref + richtext: RichText + webForceMinHeight: boolean + hasRightPadding: boolean + isActive: boolean + setRichText: (v: RichText) => void + onPhotoPasted: (uri: string) => void + onPressPublish: (richtext: RichText) => void + onNewLink: (uri: string) => void + onError: (err: string) => void + onFocus: () => void +} & Pick< + React.ComponentProps, + | 'placeholder' + | 'autoFocus' + | 'style' + | 'accessible' + | 'accessibilityLabel' + | 'accessibilityHint' +> diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index cb7ed194a..9f6cc6ae2 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,4 +1,11 @@ -import React, {useRef} from 'react' +import { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react' import {StyleSheet, View} from 'react-native' import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' import {AppBskyRichtextFacet, RichText} from '@atproto/api' @@ -16,7 +23,6 @@ import {EditorContent, type JSONContent, useEditor} from '@tiptap/react' import Graphemer from 'graphemer' import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' -import {usePalette} from '#/lib/hooks/usePalette' import {blobToDataUri, isUriImage} from '#/lib/media/util' import {useActorAutocompleteFn} from '#/state/queries/actor-autocomplete' import { @@ -27,57 +33,34 @@ import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEm import {atoms as a, useAlf} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {Portal} from '#/components/Portal' -import {Text} from '../../util/text/Text' -import {createSuggestion} from './web/Autocomplete' +import {Text} from '#/components/Typography' +import {type TextInputProps} from './TextInput.types' +import {type AutocompleteRef, createSuggestion} from './web/Autocomplete' import {type Emoji} from './web/EmojiPicker' import {LinkDecorator} from './web/LinkDecorator' import {TagDecorator} from './web/TagDecorator' -export interface TextInputRef { - focus: () => void - blur: () => void - getCursorPosition: () => DOMRect | undefined -} - -interface TextInputProps { - richtext: RichText - placeholder: string - suggestedLinks: Set - webForceMinHeight: boolean - hasRightPadding: boolean - isActive: boolean - setRichText: (v: RichText | ((v: RichText) => RichText)) => void - onPhotoPasted: (uri: string) => void - onPressPublish: (richtext: RichText) => void - onNewLink: (uri: string) => void - onError: (err: string) => void - onFocus: () => void -} - -export const TextInput = React.forwardRef(function TextInputImpl( - { - richtext, - placeholder, - webForceMinHeight, - hasRightPadding, - isActive, - setRichText, - onPhotoPasted, - onPressPublish, - onNewLink, - onFocus, - }: // onError, TODO - TextInputProps, +export function TextInput({ ref, -) { + richtext, + placeholder, + webForceMinHeight, + hasRightPadding, + isActive, + setRichText, + onPhotoPasted, + onPressPublish, + onNewLink, + onFocus, +}: TextInputProps) { const {theme: t, fonts} = useAlf() const autocomplete = useActorAutocompleteFn() - const pal = usePalette('default') const modeClass = useColorSchemeStyle('ProseMirror-light', 'ProseMirror-dark') - const [isDropping, setIsDropping] = React.useState(false) + const [isDropping, setIsDropping] = useState(false) + const autocompleteRef = useRef(null) - const extensions = React.useMemo( + const extensions = useMemo( () => [ Document, LinkDecorator, @@ -86,7 +69,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( HTMLAttributes: { class: 'mention', }, - suggestion: createSuggestion({autocomplete}), + suggestion: createSuggestion({autocomplete, autocompleteRef}), }), Paragraph, Placeholder.configure({ @@ -99,7 +82,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( [autocomplete, placeholder], ) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -109,7 +92,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [onPressPublish, isActive]) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -119,7 +102,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [isActive, onPhotoPasted]) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -296,13 +279,13 @@ export const TextInput = React.forwardRef(function TextInputImpl( [modeClass], ) - const onEmojiInserted = React.useCallback( + const onEmojiInserted = useCallback( (emoji: Emoji) => { editor?.chain().focus().insertContent(emoji.native).run() }, [editor], ) - React.useEffect(() => { + useEffect(() => { if (!isActive) { return } @@ -312,7 +295,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( } }, [onEmojiInserted, isActive]) - React.useImperativeHandle(ref, () => ({ + useImperativeHandle(ref, () => ({ focus: () => { editor?.chain().focus() }, @@ -323,9 +306,10 @@ export const TextInput = React.forwardRef(function TextInputImpl( const pos = editor?.state.selection.$anchor.pos return pos ? editor?.view.coordsAtPos(pos) : undefined }, + maybeClosePopup: () => autocompleteRef.current?.maybeClose() ?? false, })) - const inputStyle = React.useMemo(() => { + const inputStyle = useMemo(() => { const style = normalizeTextStyles( [a.text_lg, a.leading_snug, t.atoms.text], { @@ -360,10 +344,20 @@ export const TextInput = React.forwardRef(function TextInputImpl( style={styles.dropContainer} entering={FadeIn.duration(80)} exiting={FadeOut.duration(80)}> - + + style={[ + a.text_lg, + a.font_bold, + t.atoms.text_contrast_medium, + t.atoms.border_contrast_high, + styles.dropText, + ]}> Drop to add images @@ -372,7 +366,7 @@ export const TextInput = React.forwardRef(function TextInputImpl( )} ) -}) +} function editorJsonToText( json: JSONContent, diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx index 94ecb53cc..1a95736c3 100644 --- a/src/view/com/composer/text-input/web/Autocomplete.tsx +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -1,6 +1,6 @@ import {forwardRef, useEffect, useImperativeHandle, useState} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {type AppBskyActorDefs} from '@atproto/api' +import {Pressable, View} from 'react-native' +import {type AppBskyActorDefs, type ModerationOpts} from '@atproto/api' import {Trans} from '@lingui/macro' import {ReactRenderer} from '@tiptap/react' import { @@ -10,25 +10,26 @@ import { } from '@tiptap/suggestion' import tippy, {type Instance as TippyInstance} from 'tippy.js' -import {usePalette} from '#/lib/hooks/usePalette' -import {sanitizeDisplayName} from '#/lib/strings/display-names' -import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' import {type ActorAutocompleteFn} from '#/state/queries/actor-autocomplete' -import {Text} from '#/view/com/util/text/Text' -import {UserAvatar} from '#/view/com/util/UserAvatar' -import {atoms as a} from '#/alf' -import {useSimpleVerificationState} from '#/components/verification' -import {VerificationCheck} from '#/components/verification/VerificationCheck' -import {useGrapheme} from '../hooks/useGrapheme' +import {atoms as a, useTheme} from '#/alf' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' interface MentionListRef { onKeyDown: (props: SuggestionKeyDownProps) => boolean } +export interface AutocompleteRef { + maybeClose: () => boolean +} + export function createSuggestion({ autocomplete, + autocompleteRef, }: { autocomplete: ActorAutocompleteFn + autocompleteRef: React.Ref }): Omit { return { async items({query}) { @@ -40,10 +41,15 @@ export function createSuggestion({ let component: ReactRenderer | undefined let popup: TippyInstance[] | undefined + const hide = () => { + popup?.[0]?.destroy() + component?.destroy() + } + return { onStart: props => { component = new ReactRenderer(MentionList, { - props, + props: {...props, autocompleteRef, hide}, editor: props.editor, }) @@ -78,204 +84,163 @@ export function createSuggestion({ onKeyDown(props) { if (props.event.key === 'Escape') { - popup?.[0]?.hide() - - return true + return false } return component?.ref?.onKeyDown(props) || false }, onExit() { - popup?.[0]?.destroy() - component?.destroy() + hide() }, } }, } } -const MentionList = forwardRef( - function MentionListImpl(props: SuggestionProps, ref) { - const [selectedIndex, setSelectedIndex] = useState(0) - const pal = usePalette('default') +const MentionList = forwardRef< + MentionListRef, + SuggestionProps & { + autocompleteRef: React.Ref + hide: () => void + } +>(function MentionListImpl({items, command, hide, autocompleteRef}, ref) { + const [selectedIndex, setSelectedIndex] = useState(0) + const t = useTheme() + const moderationOpts = useModerationOpts() - const selectItem = (index: number) => { - const item = props.items[index] + const selectItem = (index: number) => { + const item = items[index] - if (item) { - props.command({id: item.handle}) - } + if (item) { + command({id: item.handle}) } + } - const upHandler = () => { - setSelectedIndex( - (selectedIndex + props.items.length - 1) % props.items.length, - ) - } + const upHandler = () => { + setSelectedIndex((selectedIndex + items.length - 1) % items.length) + } - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % props.items.length) - } + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } - const enterHandler = () => { - selectItem(selectedIndex) - } + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [items]) + + useImperativeHandle(autocompleteRef, () => ({ + maybeClose: () => { + hide() + return true + }, + })) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter' || event.key === 'Tab') { + enterHandler() + return true + } + + return false + }, + })) - useEffect(() => setSelectedIndex(0), [props.items]) - - useImperativeHandle(ref, () => ({ - onKeyDown: ({event}) => { - if (event.key === 'ArrowUp') { - upHandler() - return true - } - - if (event.key === 'ArrowDown') { - downHandler() - return true - } - - if (event.key === 'Enter' || event.key === 'Tab') { - enterHandler() - return true - } - - return false - }, - })) - - const {items} = props - - return ( -
- - {items.length > 0 ? ( - items.map((item, index) => { - const isSelected = selectedIndex === index - - return ( - { - selectItem(index) - }} - /> - ) - }) - ) : ( - - No result - - )} - -
- ) - }, -) + if (!moderationOpts) return null + + return ( +
+ + {items.length > 0 ? ( + items.map((item, index) => { + const isSelected = selectedIndex === index + + return ( + selectItem(index)} + onHover={() => setSelectedIndex(index)} + moderationOpts={moderationOpts} + /> + ) + }) + ) : ( + + No result + + )} + +
+ ) +}) function AutocompleteProfileCard({ profile, isSelected, - itemIndex, - totalItems, onPress, + onHover, + moderationOpts, }: { profile: AppBskyActorDefs.ProfileViewBasic isSelected: boolean - itemIndex: number - totalItems: number onPress: () => void + onHover: () => void + moderationOpts: ModerationOpts }) { - const pal = usePalette('default') - const {getGraphemeString} = useGrapheme() - const {name: displayName} = getGraphemeString( - sanitizeDisplayName(profile.displayName || sanitizeHandle(profile.handle)), - 30, // Heuristic value; can be modified - ) - const state = useSimpleVerificationState({ - profile, - }) + const t = useTheme() + return ( - - - - - {displayName} - - {state.isVerified && ( - - - - )} - - - - - {sanitizeHandle(profile.handle, '@')} - + + + + + ) } - -const styles = StyleSheet.create({ - container: { - width: 500, - borderRadius: 6, - borderWidth: 1, - borderStyle: 'solid', - padding: 4, - }, - mentionContainer: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - flexDirection: 'row', - paddingHorizontal: 12, - paddingVertical: 8, - gap: 16, - }, - firstMention: { - borderTopLeftRadius: 2, - borderTopRightRadius: 2, - }, - lastMention: { - borderBottomLeftRadius: 2, - borderBottomRightRadius: 2, - }, - avatarAndDisplayName: { - display: 'flex', - flexDirection: 'row', - alignItems: 'center', - gap: 6, - }, - noResult: { - paddingHorizontal: 12, - paddingVertical: 8, - }, -}) -- 2.51.2 From 617242371473baaea026b128532eb7fb3b0dd0ee Mon Sep 17 00:00:00 2001 From: pfrazee <1270099+pfrazee@users.noreply.github.com> Date: Fri, 29 Aug 2025 02:36:45 +0000 Subject: [PATCH 4/5] Nightly source-language update --- src/locale/locales/en/messages.po | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/locale/locales/en/messages.po b/src/locale/locales/en/messages.po index 72e5aac8a..a1cfe2bbe 100644 --- a/src/locale/locales/en/messages.po +++ b/src/locale/locales/en/messages.po @@ -2858,7 +2858,7 @@ msgstr "" msgid "Download CAR file" msgstr "" -#: src/view/com/composer/text-input/TextInput.web.tsx:367 +#: src/view/com/composer/text-input/TextInput.web.tsx:361 msgid "Drop to add images" msgstr "" @@ -5606,7 +5606,7 @@ msgid "No reposts yet" msgstr "" #: src/view/com/composer/text-input/mobile/Autocomplete.tsx:61 -#: src/view/com/composer/text-input/web/Autocomplete.tsx:172 +#: src/view/com/composer/text-input/web/Autocomplete.tsx:192 msgid "No result" msgstr "" -- 2.51.2 From 0114cfcfe10857a6e2be23f4d68489aa40456916 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Fri, 29 Aug 2025 10:36:20 +0300 Subject: [PATCH 5/5] fix hook being marked as async for no reason (#8932) --- src/locale/i18n.web.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/locale/i18n.web.ts b/src/locale/i18n.web.ts index aad3f6e0a..23a866dc2 100644 --- a/src/locale/i18n.web.ts +++ b/src/locale/i18n.web.ts @@ -182,7 +182,7 @@ export async function dynamicActivate(locale: AppLanguage) { i18n.activate(locale) } -export async function useLocaleLanguage() { +export function useLocaleLanguage() { const {appLanguage} = useLanguagePrefs() useEffect(() => { const sanitizedLanguage = sanitizeAppLanguageSetting(appLanguage)