···8383 function handleTabLayout(index: number, x: number, width: number) {
8484 if (!tabOffsets.length) {
8585 pendingTabOffsets.current[index] = {x, width}
8686- if (pendingTabOffsets.current.length === interests.length) {
8686+ // not only do we check if the length is equal to the number of interests,
8787+ // but we also need to ensure that the array isn't sparse. `.filter()`
8888+ // removes any empty slots from the array
8989+ if (
9090+ pendingTabOffsets.current.filter(o => !!o).length === interests.length
9191+ ) {
8792 setTabOffsets(pendingTabOffsets.current)
8893 }
8994 }
···205210 showsHorizontalScrollIndicator={false}
206211 decelerationRate="fast"
207212 snapToOffsets={
208208- tabOffsets.length === interests.length
213213+ tabOffsets.filter(o => !!o).length === interests.length
209214 ? tabOffsets.map(o => o.x - tokens.space.xl)
210215 : undefined
211216 }
···170170 const state = navigation.getState()
171171 // if screen is not in the current navigator, it means it's
172172 // most likely a tab screen. note: state can be undefined
173173- if (!state?.routeNames.includes(screen)) {
173173+ if (!state?.routeNames?.includes?.(screen)) {
174174 const parent = navigation.getParent()
175175 if (
176176 parent &&
···301301 return (
302302 <Button
303303 {...rest}
304304- style={[a.justify_start, flatten(rest.style)]}
304304+ style={[a.justify_start, rest.style]}
305305 role="link"
306306 accessibilityRole="link"
307307 href={href}
+2-2
src/components/Lists.tsx
···5566import {cleanError} from '#/lib/strings/errors'
77import {CenteredView} from '#/view/com/util/Views'
88-import {atoms as a, flatten, useBreakpoints, useTheme} from '#/alf'
88+import {atoms as a, useBreakpoints, useTheme} from '#/alf'
99import {Button, ButtonText} from '#/components/Button'
1010import {Error} from '#/components/Error'
1111import {Loader} from '#/components/Loader'
···4343 a.pb_lg,
4444 t.atoms.border_contrast_low,
4545 {height: height ?? 180, paddingTop: 30},
4646- flatten(style),
4646+ style,
4747 ]}>
4848 {isFetchingNextPage ? (
4949 <Loader size="xl" />
+2-7
src/components/Loader.tsx
···77 withTiming,
88} from 'react-native-reanimated'
991010-import {atoms as a, flatten, useTheme} from '#/alf'
1010+import {atoms as a, useTheme} from '#/alf'
1111import {type Props, useCommonSVGProps} from '#/components/icons/common'
1212import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
1313···3737 ]}>
3838 <Icon
3939 {...props}
4040- style={[
4141- a.absolute,
4242- a.inset_0,
4343- t.atoms.text_contrast_high,
4444- flatten(props.style),
4545- ]}
4040+ style={[a.absolute, a.inset_0, t.atoms.text_contrast_high, props.style]}
4641 />
4742 </Animated.View>
4843 )
+2-2
src/components/Loader.web.tsx
···11import {View} from 'react-native'
2233-import {atoms as a, flatten, useTheme} from '#/alf'
33+import {atoms as a, useTheme} from '#/alf'
44import {type Props, useCommonSVGProps} from '#/components/icons/common'
55import {Loader_Stroke2_Corner0_Rounded as Icon} from '#/components/icons/Loader'
66···2424 a.absolute,
2525 a.inset_0,
2626 t.atoms.text_contrast_high,
2727- flatten(props.style),
2727+ props.style,
2828 ]}
2929 />
3030 </div>
+6-6
src/components/PolicyUpdateOverlay/Overlay.tsx
···55 useSafeAreaInsets,
66} from 'react-native-safe-area-context'
77import {LinearGradient} from 'expo-linear-gradient'
88+import {utils} from '@bsky.app/alf'
89910import {isAndroid, isNative} from '#/platform/detection'
1011import {useA11y} from '#/state/a11y'
1111-import {atoms as a, flatten, useBreakpoints, useTheme, web} from '#/alf'
1212-import {transparentifyColor} from '#/alf/util/colorGeneration'
1212+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
1313import {FocusScope} from '#/components/FocusScope'
1414import {LockScroll} from '#/components/LockScroll'
1515···4747 ) : (
4848 <LinearGradient
4949 colors={[
5050- transparentifyColor(t.atoms.bg.backgroundColor, 0),
5050+ utils.alpha(t.atoms.bg.backgroundColor, 0),
5151 t.atoms.bg.backgroundColor,
5252 t.atoms.bg.backgroundColor,
5353 ]}
···9797 ]}>
9898 <LinearGradient
9999 colors={[
100100- transparentifyColor(t.atoms.bg.backgroundColor, 0),
100100+ utils.alpha(t.atoms.bg.backgroundColor, 0),
101101 t.atoms.bg.backgroundColor,
102102 ]}
103103 start={[0.5, 0]}
···113113 role="dialog"
114114 aria-role="dialog"
115115 aria-label={label}
116116- style={flatten([
116116+ style={[
117117 a.relative,
118118 a.w_full,
119119 a.p_2xl,
···128128 maxWidth: 420,
129129 }),
130130 ],
131131- ])}>
131131+ ]}>
132132 {children}
133133 </View>
134134 </FocusScope>
+82-7
src/components/PostControls/index.tsx
···11-import {memo, useState} from 'react'
11+import {memo, useMemo, useState} from 'react'
22import {type StyleProp, View, type ViewStyle} from 'react-native'
33import {
44 type AppBskyFeedDefs,
···2828 useProgressGuideControls,
2929} from '#/state/shell/progress-guide'
3030import * as Toast from '#/view/com/util/Toast'
3131-import {atoms as a, flatten, useBreakpoints} from '#/alf'
3131+import {atoms as a, useBreakpoints} from '#/alf'
3232import {Reply as Bubble} from '#/components/icons/Reply'
3333import {useFormatPostStatCount} from '#/components/PostControls/util'
3434+import * as Skele from '#/components/Skeleton'
3435import {BookmarkButton} from './BookmarkButton'
3536import {
3637 PostControlButton,
···196197 })
197198 }
198199199199- const secondaryControlSpacingStyles = flatten([
200200- {gap: 0}, // default, we want `gap` to be defined on the resulting object
201201- variant !== 'compact' && a.gap_xs,
202202- (big || gtPhone) && a.gap_sm,
203203- ])
200200+ const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
201201+ variant,
202202+ big,
203203+ gtPhone,
204204+ })
204205205206 return (
206207 <View
···352353}
353354PostControls = memo(PostControls)
354355export {PostControls}
356356+357357+export function PostControlsSkeleton({
358358+ big,
359359+ style,
360360+ variant,
361361+}: {
362362+ big?: boolean
363363+ style?: StyleProp<ViewStyle>
364364+ variant?: 'compact' | 'normal' | 'large'
365365+}) {
366366+ const {gtPhone} = useBreakpoints()
367367+368368+ const rowHeight = big ? 32 : 28
369369+ const padding = 4
370370+ const size = rowHeight - padding * 2
371371+372372+ const secondaryControlSpacingStyles = useSecondaryControlSpacingStyles({
373373+ variant,
374374+ big,
375375+ gtPhone,
376376+ })
377377+378378+ const itemStyles = {
379379+ padding,
380380+ }
381381+382382+ return (
383383+ <Skele.Row
384384+ style={[a.flex_row, a.justify_between, a.align_center, a.gap_md, style]}>
385385+ <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}>
386386+ <View
387387+ style={[itemStyles, a.flex_1, a.align_start, {marginLeft: -padding}]}>
388388+ <Skele.Pill blend size={size} />
389389+ </View>
390390+391391+ <View style={[itemStyles, a.flex_1, a.align_start]}>
392392+ <Skele.Pill blend size={size} />
393393+ </View>
394394+395395+ <View style={[itemStyles, a.flex_1, a.align_start]}>
396396+ <Skele.Pill blend size={size} />
397397+ </View>
398398+ </View>
399399+ <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}>
400400+ <View style={itemStyles}>
401401+ <Skele.Circle blend size={size} />
402402+ </View>
403403+ <View style={itemStyles}>
404404+ <Skele.Circle blend size={size} />
405405+ </View>
406406+ <View style={itemStyles}>
407407+ <Skele.Circle blend size={size} />
408408+ </View>
409409+ </View>
410410+ </Skele.Row>
411411+ )
412412+}
413413+414414+function useSecondaryControlSpacingStyles({
415415+ variant,
416416+ big,
417417+ gtPhone,
418418+}: {
419419+ variant?: 'compact' | 'normal' | 'large'
420420+ big?: boolean
421421+ gtPhone: boolean
422422+}) {
423423+ return useMemo(() => {
424424+ let gap = 0 // default, we want `gap` to be defined on the resulting object
425425+ if (variant !== 'compact') gap = a.gap_xs.gap
426426+ if (big || gtPhone) gap = a.gap_sm.gap
427427+ return {gap}
428428+ }, [variant, big, gtPhone])
429429+}
···3636 const handleIncomingURL = async (url: string) => {
3737 if (isIOS) {
3838 // Close in-app browser if it's open (iOS only)
3939- // TEMP: promise never resolves if the browser is not open, so don't await
4040- // https://github.com/expo/expo/issues/40710
4141- // add the await back when possible since it's needed to fix the IAB share bug -sfn
4242- /* await */ WebBrowser.dismissBrowser().catch(() => {})
3939+ await WebBrowser.dismissBrowser().catch(() => {})
4340 }
44414542 const referrerInfo = Referrer.getReferrerInfo()
···11+import {useCallback} from 'react'
22+import {msg} from '@lingui/macro'
33+import {useLingui} from '@lingui/react'
44+55+import {isNative} from '#/platform/detection'
66+import * as Toast from '#/components/Toast'
77+import {saveImageToMediaLibrary} from './manip'
88+99+/**
1010+ * Same as `saveImageToMediaLibrary`, but also handles permissions and toasts
1111+ *
1212+ * iOS doesn't not require permissions to save images to the media library,
1313+ * so this file is platform-split as it's much simpler than the Android version.
1414+ */
1515+export function useSaveImageToMediaLibrary() {
1616+ const {_} = useLingui()
1717+ return useCallback(
1818+ async (uri: string) => {
1919+ if (!isNative) {
2020+ throw new Error('useSaveImageToMediaLibrary is native only')
2121+ }
2222+2323+ try {
2424+ await saveImageToMediaLibrary({uri})
2525+ Toast.show(_(msg`Image saved`))
2626+ } catch (e: any) {
2727+ Toast.show(_(msg`Failed to save image: ${String(e)}`), {type: 'error'})
2828+ }
2929+ },
3030+ [_],
3131+ )
3232+}
+18-9
src/lib/media/save-image.ts
···11import {useCallback} from 'react'
22import * as MediaLibrary from 'expo-media-library'
33-import {t} from '@lingui/macro'
33+import {msg} from '@lingui/macro'
44+import {useLingui} from '@lingui/react'
4556import {isNative} from '#/platform/detection'
66-import * as Toast from '#/view/com/util/Toast'
77+import * as Toast from '#/components/Toast'
78import {saveImageToMediaLibrary} from './manip'
89910/**
1011 * Same as `saveImageToMediaLibrary`, but also handles permissions and toasts
1112 */
1213export function useSaveImageToMediaLibrary() {
1414+ const {_} = useLingui()
1315 const [permissionResponse, requestPermission, getPermission] =
1416 MediaLibrary.usePermissions({
1517 granularPermissions: ['photo'],
···2325 async function save() {
2426 try {
2527 await saveImageToMediaLibrary({uri})
2626- Toast.show(t`Image saved`)
2828+2929+ Toast.show(_(msg`Image saved`))
2730 } catch (e: any) {
2828- Toast.show(t`Failed to save image: ${String(e)}`, 'xmark')
3131+ Toast.show(_(msg`Failed to save image: ${String(e)}`), {
3232+ type: 'error',
3333+ })
2934 }
3035 }
3136···4247 } else {
4348 // since we've been explicitly denied, show a toast.
4449 Toast.show(
4545- t`Images cannot be saved unless permission is granted to access your photo library.`,
4646- 'xmark',
5050+ _(
5151+ msg`Images cannot be saved unless permission is granted to access your photo library.`,
5252+ ),
5353+ {type: 'error'},
4754 )
4855 }
4956 } else {
5057 Toast.show(
5151- t`Permission to access your photo library was denied. Please enable it in your system settings.`,
5252- 'xmark',
5858+ _(
5959+ msg`Permission to access your photo library was denied. Please enable it in your system settings.`,
6060+ ),
6161+ {type: 'error'},
5362 )
5463 }
5564 }
5665 },
5757- [permissionResponse, requestPermission, getPermission],
6666+ [permissionResponse, requestPermission, getPermission, _],
5867 )
5968}
+13-13
src/locale/locales/en/messages.po
···36343634msgid "Feed unavailable"
36353635msgstr ""
3636363636373637+#: src/view/shell/desktop/RightNav.tsx:105
36373638#: src/view/shell/desktop/RightNav.tsx:106
36383638-#: src/view/shell/desktop/RightNav.tsx:107
36393639#: src/view/shell/Drawer.tsx:368
36403640msgid "Feedback"
36413641msgstr ""
···37123712msgid "Finance"
37133713msgstr ""
3714371437153715-#: src/view/com/posts/CustomFeedEmptyState.tsx:48
37153715+#: src/view/com/posts/CustomFeedEmptyState.tsx:71
37163716#: src/view/com/posts/FollowingEmptyState.tsx:53
37173717#: src/view/com/posts/FollowingEndOfFeed.tsx:54
37183718msgid "Find accounts to follow"
···4213421342144214#: src/screens/Settings/Settings.tsx:236
42154215#: src/screens/Settings/Settings.tsx:240
42164216+#: src/view/shell/desktop/RightNav.tsx:123
42164217#: src/view/shell/desktop/RightNav.tsx:124
42174217-#: src/view/shell/desktop/RightNav.tsx:125
42184218#: src/view/shell/Drawer.tsx:381
42194219msgid "Help"
42204220msgstr ""
···44474447msgid "Illegal and Urgent"
44484448msgstr ""
4449444944504450-#: src/view/com/util/images/Gallery.tsx:70
44504450+#: src/view/com/util/images/Gallery.tsx:75
44514451msgid "Image"
44524452msgstr ""
44534453···50525052msgid "Logged-out visibility"
50535053msgstr ""
5054505450555055-#: src/view/shell/desktop/RightNav.tsx:134
50555055+#: src/view/shell/desktop/RightNav.tsx:133
50565056msgid "Logo by @sawaratsuki.bsky.social"
50575057msgstr ""
5058505850595059-#: src/view/shell/desktop/RightNav.tsx:131
50595059+#: src/view/shell/desktop/RightNav.tsx:130
50605060#: src/view/shell/Drawer.tsx:709
50615061msgid "Logo by <0>@sawaratsuki.bsky.social</0>"
50625062msgstr ""
···52765276msgid "Moderator has chosen to set a general warning on the content."
52775277msgstr ""
5278527852795279-#: src/view/shell/desktop/Feeds.tsx:108
52805280-#: src/view/shell/desktop/Feeds.tsx:118
52795279+#: src/view/shell/desktop/Feeds.tsx:104
52805280+#: src/view/shell/desktop/Feeds.tsx:114
52815281msgid "More feeds"
52825282msgstr ""
52835283···66136613msgid "Prioritize your Follows"
66146614msgstr ""
6615661566166616+#: src/view/shell/desktop/RightNav.tsx:113
66166617#: src/view/shell/desktop/RightNav.tsx:114
66176617-#: src/view/shell/desktop/RightNav.tsx:115
66186618msgid "Privacy"
66196619msgstr ""
66206620···86148614msgid "Tell us a little more"
86158615msgstr ""
8616861686178617+#: src/view/shell/desktop/RightNav.tsx:119
86178618#: src/view/shell/desktop/RightNav.tsx:120
86188618-#: src/view/shell/desktop/RightNav.tsx:121
86198619msgid "Terms"
86208620msgstr ""
86218621···90009000msgid "This feed is currently receiving high traffic and is temporarily unavailable. Please try again later."
90019001msgstr ""
9002900290039003-#: src/view/com/posts/CustomFeedEmptyState.tsx:38
90039003+#: src/view/com/posts/CustomFeedEmptyState.tsx:61
90049004msgid "This feed is empty! You may need to follow more users or tune your language settings."
90059005msgstr ""
90069006···99879987msgid "View your verifications"
99889988msgstr ""
9989998999909990-#: src/view/com/util/images/AutoSizedImage.tsx:206
99919991-#: src/view/com/util/images/AutoSizedImage.tsx:229
99909990+#: src/view/com/util/images/AutoSizedImage.tsx:207
99919991+#: src/view/com/util/images/AutoSizedImage.tsx:234
99929992msgid "Views full image"
99939993msgstr ""
99949994
···11import React from 'react'
22-import {Pressable, StyleSheet, View} from 'react-native'
22+import {Pressable, View} from 'react-native'
33import {msg} from '@lingui/macro'
44import {useLingui} from '@lingui/react'
55import Graphemer from 'graphemer'
···1919 type EmojiPickerPosition,
2020} from '#/view/com/composer/text-input/web/EmojiPicker'
2121import * as Toast from '#/view/com/util/Toast'
2222-import {atoms as a, useTheme} from '#/alf'
2222+import {atoms as a, flatten, useTheme} from '#/alf'
2323import {Button} from '#/components/Button'
2424import {useSharedInputStyles} from '#/components/forms/TextField'
2525import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
···199199 </Button>
200200 <TextareaAutosize
201201 ref={textAreaRef}
202202- style={StyleSheet.flatten([
202202+ style={flatten([
203203 a.flex_1,
204204 a.px_sm,
205205 a.border_0,