deer social fork for personal usage. but you might see a use idk. github mirror

Merge branch 'main' of https://github.com/bluesky-social/social-app

Changed files
+3103 -702
assets
bskylink
bskyweb
cmd
bskyweb
src
+6
.env.example
··· 33 33 34 34 # Bitdrift API key. If undefined, Bitdrift will be disabled. 35 35 EXPO_PUBLIC_BITDRIFT_API_KEY= 36 + 37 + # bapp-config web worker URL 38 + BAPP_CONFIG_DEV_URL= 39 + 40 + # Dev-only passthrough value for bapp-config web worker 41 + BAPP_CONFIG_DEV_BYPASS_SECRET=
+1
app.config.js
··· 384 384 }, 385 385 ], 386 386 ['expo-screen-orientation', {initialOrientation: 'PORTRAIT_UP'}], 387 + ['expo-location'], 387 388 ].filter(Boolean), 388 389 extra: { 389 390 eas: {
+1
assets/icons/bookmark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z"/></svg>
+1
assets/icons/bookmarkDeleteLarge.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#405168" d="M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326a3.38 3.38 0 0 1 1.475 1.475c.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.6.6 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.38 1.38 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7 7 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.38 3.38 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.63 2.63 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.63.63 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.63.63 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.63 2.63 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z"/></svg>
+1
assets/icons/bookmarkFilled.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#006AFF" d="M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2 2 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z"/></svg>
+1
assets/icons/pinLocationFilled_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M12.591 21.806h.002l.001-.002.006-.004.018-.014a10 10 0 0 0 .304-.235 26 26 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a26 26 0 0 0 3.333 3.196 16 16 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z" clip-rule="evenodd"/></svg>
+1
assets/icons/pinLocation_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a26 26 0 0 1-3.333 3.197q-.152.12-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1 1 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a24 24 0 0 1-1.066-.877 26 26 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A24 24 0 0 0 12 19.723a24 24 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z"/></svg>
+1
assets/icons/reply.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
+1
assets/icons/replyFiled.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#080B12" d="M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z"/></svg>
assets/images/bookmarks_announcement_nux.webp

This is a binary file and will not be displayed.

assets/images/welcome-modal-bg.jpg

This is a binary file and will not be displayed.

+2
bskylink/src/db/migrations/index.ts
··· 1 1 import * as init from './001-init.js' 2 2 import * as safelink from './002-safelink.js' 3 + import * as safelinkCursorConstraint from './003-safelink-cursor-constraint.js' 3 4 4 5 export default { 5 6 '001': init, 6 7 '002': safelink, 8 + '003': safelinkCursorConstraint, 7 9 }
+3
bskyweb/cmd/bskyweb/server.go
··· 331 331 e.GET("/starter-pack-short/:code", server.WebGeneric) 332 332 e.GET("/start/:handleOrDID/:rkey", server.WebStarterPack) 333 333 334 + // bookmarks 335 + e.GET("/saved", server.WebGeneric) 336 + 334 337 // ipcc 335 338 e.GET("/ipcc", server.WebIpCC) 336 339
+3 -2
package.json
··· 1 1 { 2 2 "name": "deer.social", 3 - "version": "1.107.0", 3 + "version": "1.108.0", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=18" ··· 71 71 "icons:optimize": "svgo -f ./assets/icons" 72 72 }, 73 73 "dependencies": { 74 - "@atproto/api": "^0.16.2", 74 + "@atproto/api": "^0.16.7", 75 75 "@bitdrift/react-native": "^0.6.8", 76 76 "@braintree/sanitize-url": "^6.0.2", 77 77 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", ··· 151 151 "expo-linear-gradient": "~14.1.5", 152 152 "expo-linking": "~7.1.5", 153 153 "expo-localization": "~16.1.5", 154 + "expo-location": "~18.1.6", 154 155 "expo-media-library": "~17.1.7", 155 156 "expo-notifications": "~0.31.3", 156 157 "expo-screen-orientation": "~8.1.7",
+7 -6
src/App.native.tsx
··· 32 32 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 33 33 import {listenSessionDropped} from '#/state/events' 34 34 import { 35 - beginResolveGeolocation, 36 - ensureGeolocationResolved, 35 + beginResolveGeolocationConfig, 36 + ensureGeolocationConfigIsResolved, 37 37 Provider as GeolocationProvider, 38 38 } from '#/state/geolocation' 39 39 import {GlobalGestureEventsProvider} from '#/state/global-gesture-events' ··· 91 91 /** 92 92 * Begin geolocation ASAP 93 93 */ 94 - beginResolveGeolocation() 94 + beginResolveGeolocationConfig() 95 95 96 96 function InnerApp() { 97 97 const [isReady, setIsReady] = React.useState(false) ··· 203 203 const [isReady, setReady] = useState(false) 204 204 205 205 React.useEffect(() => { 206 - Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 207 - setReady(true), 208 - ) 206 + Promise.all([ 207 + initPersistedState(), 208 + ensureGeolocationConfigIsResolved(), 209 + ]).then(() => setReady(true)) 209 210 }, []) 210 211 211 212 if (!isReady) {
+7 -6
src/App.web.tsx
··· 21 21 import {Provider as EmailVerificationProvider} from '#/state/email-verification' 22 22 import {listenSessionDropped} from '#/state/events' 23 23 import { 24 - beginResolveGeolocation, 25 - ensureGeolocationResolved, 24 + beginResolveGeolocationConfig, 25 + ensureGeolocationConfigIsResolved, 26 26 Provider as GeolocationProvider, 27 27 } from '#/state/geolocation' 28 28 import {Provider as HomeBadgeProvider} from '#/state/home-badge' ··· 69 69 /** 70 70 * Begin geolocation ASAP 71 71 */ 72 - beginResolveGeolocation() 72 + beginResolveGeolocationConfig() 73 73 74 74 function InnerApp() { 75 75 const [isReady, setIsReady] = React.useState(false) ··· 178 178 const [isReady, setReady] = useState(false) 179 179 180 180 React.useEffect(() => { 181 - Promise.all([initPersistedState(), ensureGeolocationResolved()]).then(() => 182 - setReady(true), 183 - ) 181 + Promise.all([ 182 + initPersistedState(), 183 + ensureGeolocationConfigIsResolved(), 184 + ]).then(() => setReady(true)) 184 185 }, []) 185 186 186 187 if (!isReady) {
+9
src/Navigation.tsx
··· 71 71 import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' 72 72 import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' 73 73 import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' 74 + import {BookmarksScreen} from '#/screens/Bookmarks' 74 75 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' 75 76 import HashtagScreen from '#/screens/Hashtag' 76 77 import {LogScreen} from '#/screens/Log' ··· 606 607 getComponent={() => VideoFeed} 607 608 options={{ 608 609 title: title(msg`Video Feed`), 610 + requireAuth: true, 611 + }} 612 + /> 613 + <Stack.Screen 614 + name="Bookmarks" 615 + getComponent={() => BookmarksScreen} 616 + options={{ 617 + title: title(msg`Saved Posts`), 609 618 requireAuth: true, 610 619 }} 611 620 />
+1
src/alf/atoms.ts
··· 1063 1063 }), 1064 1064 fade_out: web({ 1065 1065 animation: 'fadeOut ease-out 0.15s', 1066 + animationFillMode: 'forwards', 1066 1067 }), 1067 1068 zoom_in: web({ 1068 1069 animation: 'zoomIn ease-out 0.1s',
+130 -45
src/components/BlockedGeoOverlay.tsx
··· 6 6 7 7 import {logger} from '#/logger' 8 8 import {isWeb} from '#/platform/detection' 9 + import {useDeviceGeolocationApi} from '#/state/geolocation' 9 10 import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 11 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 14 + import {Divider} from '#/components/Divider' 10 15 import {Full as Logo, Mark} from '#/components/icons/Logo' 16 + import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 11 17 import {SimpleInlineLinkText as InlineLinkText} from '#/components/Link' 18 + import {Outlet as PortalOutlet} from '#/components/Portal' 19 + import * as Toast from '#/components/Toast' 12 20 import {Text} from '#/components/Typography' 21 + import {BottomSheetOutlet} from '#/../modules/bottom-sheet' 13 22 14 23 export function BlockedGeoOverlay() { 15 24 const t = useTheme() 16 25 const {_} = useLingui() 17 26 const {gtPhone} = useBreakpoints() 18 27 const insets = useSafeAreaInsets() 28 + const geoDialog = Dialog.useDialogControl() 29 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 19 30 20 31 useEffect(() => { 21 32 // just counting overall hits here ··· 51 62 ] 52 63 53 64 return ( 54 - <ScrollView 55 - contentContainerStyle={[ 56 - a.px_2xl, 57 - { 58 - paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 59 - paddingBottom: 100, 60 - }, 61 - ]}> 62 - <View 63 - style={[ 64 - a.mx_auto, 65 - web({ 66 - maxWidth: 440, 67 - paddingTop: gtPhone ? '8vh' : undefined, 68 - }), 65 + <> 66 + <ScrollView 67 + contentContainerStyle={[ 68 + a.px_2xl, 69 + { 70 + paddingTop: isWeb ? a.p_5xl.padding : insets.top + a.p_2xl.padding, 71 + paddingBottom: 100, 72 + }, 69 73 ]}> 70 - <View style={[a.align_start]}> 71 - <View 72 - style={[ 73 - a.pl_md, 74 - a.pr_lg, 75 - a.py_sm, 76 - a.rounded_full, 77 - a.flex_row, 78 - a.align_center, 79 - a.gap_xs, 80 - { 81 - backgroundColor: t.palette.primary_25, 82 - }, 83 - ]}> 84 - <Mark fill={t.palette.primary_600} width={14} /> 85 - <Text 74 + <View 75 + style={[ 76 + a.mx_auto, 77 + web({ 78 + maxWidth: 380, 79 + paddingTop: gtPhone ? '8vh' : undefined, 80 + }), 81 + ]}> 82 + <View style={[a.align_start]}> 83 + <View 86 84 style={[ 87 - a.font_bold, 85 + a.pl_md, 86 + a.pr_lg, 87 + a.py_sm, 88 + a.rounded_full, 89 + a.flex_row, 90 + a.align_center, 91 + a.gap_xs, 88 92 { 89 - color: t.palette.primary_600, 93 + backgroundColor: t.palette.primary_25, 90 94 }, 91 95 ]}> 92 - <Trans>Announcement</Trans> 93 - </Text> 96 + <Mark fill={t.palette.primary_600} width={14} /> 97 + <Text 98 + style={[ 99 + a.font_bold, 100 + { 101 + color: t.palette.primary_600, 102 + }, 103 + ]}> 104 + <Trans>Announcement</Trans> 105 + </Text> 106 + </View> 107 + </View> 108 + 109 + <View style={[a.gap_lg, {paddingTop: 32}]}> 110 + {blocks.map((block, index) => ( 111 + <Text key={index} style={[textStyles]}> 112 + {block} 113 + </Text> 114 + ))} 94 115 </View> 95 - </View> 116 + 117 + {!isWeb && ( 118 + <> 119 + <View style={[a.pt_2xl]}> 120 + <Divider /> 121 + </View> 122 + 123 + <View style={[a.mt_xl, a.align_start]}> 124 + <Text 125 + style={[a.text_lg, a.font_heavy, a.leading_snug, a.pb_xs]}> 126 + <Trans>Not in Mississippi?</Trans> 127 + </Text> 128 + <Text 129 + style={[ 130 + a.text_sm, 131 + a.leading_snug, 132 + t.atoms.text_contrast_medium, 133 + a.pb_md, 134 + ]}> 135 + <Trans> 136 + Confirm your location with GPS. Your location data is not 137 + tracked and does not leave your device. 138 + </Trans> 139 + </Text> 140 + <Button 141 + label={_(msg`Confirm your location`)} 142 + onPress={() => geoDialog.open()} 143 + size="small" 144 + color="primary_subtle"> 145 + <ButtonIcon icon={LocationIcon} /> 146 + <ButtonText> 147 + <Trans>Confirm your location</Trans> 148 + </ButtonText> 149 + </Button> 150 + </View> 151 + 152 + <DeviceLocationRequestDialog 153 + control={geoDialog} 154 + onLocationAcquired={props => { 155 + if (props.geolocationStatus.isAgeBlockedGeo) { 156 + props.disableDialogAction() 157 + props.setDialogError( 158 + _( 159 + msg`We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time.`, 160 + ), 161 + ) 162 + } else { 163 + props.closeDialog(() => { 164 + // set this after close! 165 + setDeviceGeolocation({ 166 + countryCode: props.geolocationStatus.countryCode, 167 + regionCode: props.geolocationStatus.regionCode, 168 + }) 169 + Toast.show(_(msg`Thanks! You're all set.`), { 170 + type: 'success', 171 + }) 172 + }) 173 + } 174 + }} 175 + /> 176 + </> 177 + )} 96 178 97 - <View style={[a.gap_lg, {paddingTop: 32, paddingBottom: 48}]}> 98 - {blocks.map((block, index) => ( 99 - <Text key={index} style={[textStyles]}> 100 - {block} 101 - </Text> 102 - ))} 179 + <View style={[{paddingTop: 48}]}> 180 + <Logo width={120} textFill={t.atoms.text.color} /> 181 + </View> 103 182 </View> 183 + </ScrollView> 104 184 105 - <Logo width={120} textFill={t.atoms.text.color} /> 106 - </View> 107 - </ScrollView> 185 + {/* 186 + * While this blocking overlay is up, other dialogs in the shell 187 + * are not mounted, so it _should_ be safe to use these here 188 + * without fear of other modals showing up. 189 + */} 190 + <BottomSheetOutlet /> 191 + <PortalOutlet /> 192 + </> 108 193 ) 109 194 }
+136
src/components/PostControls/BookmarkButton.tsx
··· 1 + import {memo} from 'react' 2 + import {type Insets} from 'react-native' 3 + import {type AppBskyFeedDefs} from '@atproto/api' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import type React from 'react' 7 + 8 + import {useCleanError} from '#/lib/hooks/useCleanError' 9 + import {logger} from '#/logger' 10 + import {type Shadow} from '#/state/cache/post-shadow' 11 + import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 12 + import {useTheme} from '#/alf' 13 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 14 + import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 15 + import * as toast from '#/components/Toast' 16 + import {PostControlButton, PostControlButtonIcon} from './PostControlButton' 17 + 18 + export const BookmarkButton = memo(function BookmarkButton({ 19 + post, 20 + big, 21 + logContext, 22 + hitSlop, 23 + }: { 24 + post: Shadow<AppBskyFeedDefs.PostView> 25 + big?: boolean 26 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 27 + hitSlop?: Insets 28 + }): React.ReactNode { 29 + const t = useTheme() 30 + const {_} = useLingui() 31 + const {mutateAsync: bookmark} = useBookmarkMutation() 32 + const cleanError = useCleanError() 33 + 34 + const {viewer} = post 35 + const isBookmarked = !!viewer?.bookmarked 36 + 37 + const undoLabel = _( 38 + msg({ 39 + message: `Undo`, 40 + context: `Button label to undo saving/removing a post from saved posts.`, 41 + }), 42 + ) 43 + 44 + const save = async ({disableUndo}: {disableUndo?: boolean} = {}) => { 45 + try { 46 + await bookmark({ 47 + action: 'create', 48 + post, 49 + }) 50 + 51 + logger.metric('post:bookmark', {logContext}) 52 + 53 + toast.show( 54 + <toast.Outer> 55 + <toast.Icon /> 56 + <toast.Text> 57 + <Trans>Post saved</Trans> 58 + </toast.Text> 59 + {!disableUndo && ( 60 + <toast.Action 61 + label={undoLabel} 62 + onPress={() => remove({disableUndo: true})}> 63 + {undoLabel} 64 + </toast.Action> 65 + )} 66 + </toast.Outer>, 67 + { 68 + type: 'success', 69 + }, 70 + ) 71 + } catch (e: any) { 72 + const {raw, clean} = cleanError(e) 73 + toast.show(clean || raw || e, { 74 + type: 'error', 75 + }) 76 + } 77 + } 78 + 79 + const remove = async ({disableUndo}: {disableUndo?: boolean} = {}) => { 80 + try { 81 + await bookmark({ 82 + action: 'delete', 83 + uri: post.uri, 84 + }) 85 + 86 + logger.metric('post:unbookmark', {logContext}) 87 + 88 + toast.show( 89 + <toast.Outer> 90 + <toast.Icon icon={TrashIcon} /> 91 + <toast.Text> 92 + <Trans>Removed from saved posts</Trans> 93 + </toast.Text> 94 + {!disableUndo && ( 95 + <toast.Action 96 + label={undoLabel} 97 + onPress={() => save({disableUndo: true})}> 98 + {undoLabel} 99 + </toast.Action> 100 + )} 101 + </toast.Outer>, 102 + ) 103 + } catch (e: any) { 104 + const {raw, clean} = cleanError(e) 105 + toast.show(clean || raw || e, { 106 + type: 'error', 107 + }) 108 + } 109 + } 110 + 111 + const onHandlePress = async () => { 112 + if (isBookmarked) { 113 + await remove() 114 + } else { 115 + await save() 116 + } 117 + } 118 + 119 + return ( 120 + <PostControlButton 121 + testID="postBookmarkBtn" 122 + big={big} 123 + label={ 124 + isBookmarked 125 + ? _(msg`Remove from saved posts`) 126 + : _(msg`Add to saved posts`) 127 + } 128 + onPress={onHandlePress} 129 + hitSlop={hitSlop}> 130 + <PostControlButtonIcon 131 + fill={isBookmarked ? t.palette.primary_500 : undefined} 132 + icon={isBookmarked ? BookmarkFilled : Bookmark} 133 + /> 134 + </PostControlButton> 135 + ) 136 + })
+20 -7
src/components/PostControls/PostControlButton.tsx
··· 1 1 import {createContext, useContext, useMemo} from 'react' 2 - import {type GestureResponderEvent, type View} from 'react-native' 2 + import {type GestureResponderEvent, type Insets, type View} from 'react-native' 3 3 4 - import {POST_CTRL_HITSLOP} from '#/lib/constants' 5 4 import {useHaptics} from '#/lib/haptics' 6 5 import {atoms as a, useTheme} from '#/alf' 7 6 import {Button, type ButtonProps} from '#/components/Button' 8 7 import {type Props as SVGIconProps} from '#/components/icons/common' 9 8 import {Text, type TextProps} from '#/components/Typography' 9 + 10 + export const DEFAULT_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 10 11 11 12 const PostControlContext = createContext<{ 12 13 big?: boolean ··· 25 26 active, 26 27 activeColor, 27 28 ...props 28 - }: ButtonProps & { 29 + }: Omit<ButtonProps, 'hitSlop'> & { 29 30 ref?: React.Ref<View> 30 31 active?: boolean 31 32 big?: boolean 32 33 color?: string 33 34 activeColor?: string 35 + hitSlop?: Insets 34 36 }) { 35 37 const t = useTheme() 36 38 const playHaptic = useHaptics() ··· 83 85 shape="round" 84 86 variant="ghost" 85 87 color="secondary" 86 - hitSlop={POST_CTRL_HITSLOP} 87 - {...props}> 88 + {...props} 89 + hitSlop={{ 90 + ...DEFAULT_HITSLOP, 91 + ...(props.hitSlop || {}), 92 + }}> 88 93 {typeof children === 'function' ? ( 89 94 args => ( 90 95 <PostControlContext.Provider value={ctx}> ··· 102 107 103 108 export function PostControlButtonIcon({ 104 109 icon: Comp, 105 - }: { 110 + style, 111 + ...rest 112 + }: SVGIconProps & { 106 113 icon: React.ComponentType<SVGIconProps> 107 114 }) { 108 115 const {big, color} = useContext(PostControlContext) 109 116 110 - return <Comp style={[color, a.pointer_events_none]} width={big ? 22 : 18} /> 117 + return ( 118 + <Comp 119 + style={[color, a.pointer_events_none, style]} 120 + {...rest} 121 + width={big ? 22 : 18} 122 + /> 123 + ) 111 124 } 112 125 113 126 export function PostControlButtonText({style, ...props}: TextProps) {
+5 -1
src/components/PostControls/PostMenu/index.tsx
··· 1 1 import {memo, useMemo, useState} from 'react' 2 + import {type Insets} from 'react-native' 2 3 import { 3 4 type AppBskyFeedDefs, 4 5 type AppBskyFeedPost, ··· 28 29 timestamp, 29 30 threadgateRecord, 30 31 onShowLess, 32 + hitSlop, 31 33 }: { 32 34 testID: string 33 35 post: Shadow<AppBskyFeedDefs.PostView> ··· 39 41 timestamp: string 40 42 threadgateRecord?: AppBskyFeedThreadgate.Record 41 43 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 44 + hitSlop?: Insets 42 45 }): React.ReactNode => { 43 46 const {_} = useLingui() 44 47 ··· 66 69 testID="postDropdownBtn" 67 70 big={big} 68 71 label={props.accessibilityLabel} 69 - {...props}> 72 + {...props} 73 + hitSlop={hitSlop}> 70 74 <PostControlButtonIcon icon={DotsHorizontal} /> 71 75 </PostControlButton> 72 76 )
+5 -4
src/components/PostControls/RepostButton.tsx
··· 5 5 6 6 import {useHaptics} from '#/lib/haptics' 7 7 import {useRequireAuth} from '#/state/session' 8 - import {formatCount} from '#/view/com/util/numeric/format' 9 8 import {atoms as a, useTheme} from '#/alf' 10 9 import {Button, ButtonText} from '#/components/Button' 11 10 import * as Dialog from '#/components/Dialog' 12 11 import {CloseQuote_Stroke2_Corner1_Rounded as Quote} from '#/components/icons/Quote' 13 - import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 12 + import {Repost_Stroke2_Corner3_Rounded as Repost} from '#/components/icons/Repost' 13 + import {useFormatPostStatCount} from '#/components/PostControls/util' 14 14 import {Text} from '#/components/Typography' 15 15 import { 16 16 PostControlButton, ··· 36 36 embeddingDisabled, 37 37 }: Props): React.ReactNode => { 38 38 const t = useTheme() 39 - const {_, i18n} = useLingui() 39 + const {_} = useLingui() 40 40 const requireAuth = useRequireAuth() 41 41 const dialogControl = Dialog.useDialogControl() 42 + const formatPostStatCount = useFormatPostStatCount() 42 43 43 44 const onPress = () => requireAuth(() => dialogControl.open()) 44 45 ··· 86 87 <PostControlButtonIcon icon={Repost} /> 87 88 {typeof repostCount !== 'undefined' && repostCount > 0 && ( 88 89 <PostControlButtonText testID="repostCount"> 89 - {formatCount(i18n, repostCount)} 90 + {formatPostStatCount(repostCount)} 90 91 </PostControlButtonText> 91 92 )} 92 93 </PostControlButton>
+5 -1
src/components/PostControls/ShareMenu/index.tsx
··· 1 1 import {memo, useMemo, useState} from 'react' 2 + import {type Insets} from 'react-native' 2 3 import { 3 4 type AppBskyFeedDefs, 4 5 type AppBskyFeedPost, ··· 34 35 timestamp, 35 36 threadgateRecord, 36 37 onShare, 38 + hitSlop, 37 39 }: { 38 40 testID: string 39 41 post: Shadow<AppBskyFeedDefs.PostView> ··· 43 45 timestamp: string 44 46 threadgateRecord?: AppBskyFeedThreadgate.Record 45 47 onShare: () => void 48 + hitSlop?: Insets 46 49 }): React.ReactNode => { 47 50 const {_} = useLingui() 48 51 const gate = useGate() ··· 92 95 big={big} 93 96 label={props.accessibilityLabel} 94 97 {...props} 95 - onLongPress={native(onNativeLongPress)}> 98 + onLongPress={native(onNativeLongPress)} 99 + hitSlop={hitSlop}> 96 100 <PostControlButtonIcon icon={ShareIcon} /> 97 101 </PostControlButton> 98 102 )
+125 -96
src/components/PostControls/index.tsx
··· 24 24 ProgressGuideAction, 25 25 useProgressGuideControls, 26 26 } from '#/state/shell/progress-guide' 27 - import {formatCount} from '#/view/com/util/numeric/format' 28 27 import * as Toast from '#/view/com/util/Toast' 29 - import {atoms as a, useBreakpoints} from '#/alf' 30 - import {Bubble_Stroke2_Corner2_Rounded as Bubble} from '#/components/icons/Bubble' 28 + import {atoms as a, flatten, useBreakpoints} from '#/alf' 29 + import {Reply as Bubble} from '#/components/icons/Reply' 30 + import {useFormatPostStatCount} from '#/components/PostControls/util' 31 + import {BookmarkButton} from './BookmarkButton' 31 32 import { 32 33 PostControlButton, 33 34 PostControlButtonIcon, ··· 51 52 threadgateRecord, 52 53 onShowLess, 53 54 viaRepost, 55 + variant, 54 56 }: { 55 57 big?: boolean 56 58 post: Shadow<AppBskyFeedDefs.PostView> ··· 65 67 threadgateRecord?: AppBskyFeedThreadgate.Record 66 68 onShowLess?: (interaction: AppBskyFeedDefs.Interaction) => void 67 69 viaRepost?: {uri: string; cid: string} 70 + variant?: 'compact' | 'normal' | 'large' 68 71 }): React.ReactNode => { 69 - const {_, i18n} = useLingui() 70 - const {gtMobile} = useBreakpoints() 72 + const {_} = useLingui() 71 73 const {openComposer} = useOpenComposer() 72 74 const {feedDescriptor} = useFeedFeedbackContext() 73 75 const [queueLike, queueUnlike] = usePostLikeMutationQueue( ··· 92 94 post.author.viewer?.blockingByList, 93 95 ) 94 96 const replyDisabled = post.viewer?.replyDisabled 97 + const {gtPhone} = useBreakpoints() 98 + const formatPostStatCount = useFormatPostStatCount() 95 99 96 100 const [hasLikeIconBeenToggled, setHasLikeIconBeenToggled] = useState(false) 97 101 ··· 184 188 }) 185 189 } 186 190 191 + const secondaryControlSpacingStyles = flatten([ 192 + {gap: 0}, // default, we want `gap` to be defined on the resulting object 193 + variant !== 'compact' && a.gap_xs, 194 + (big || gtPhone) && a.gap_sm, 195 + ]) 196 + 187 197 return ( 188 198 <View 189 199 style={[ ··· 191 201 a.justify_between, 192 202 a.align_center, 193 203 !big && a.pt_2xs, 204 + a.gap_md, 194 205 style, 195 206 ]}> 196 - <View 197 - style={[ 198 - big ? a.align_center : [a.flex_1, a.align_start, {marginLeft: -6}], 199 - replyDisabled ? {opacity: 0.5} : undefined, 200 - ]}> 201 - <PostControlButton 202 - testID="replyBtn" 203 - onPress={ 204 - !replyDisabled ? () => requireAuth(() => onPressReply()) : undefined 205 - } 206 - label={_( 207 - msg({ 208 - message: `Reply (${plural(post.replyCount || 0, { 209 - one: '# reply', 210 - other: '# replies', 211 - })})`, 212 - comment: 213 - 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 214 - }), 215 - )} 216 - big={big}> 217 - <PostControlButtonIcon icon={Bubble} /> 218 - {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 219 - <PostControlButtonText> 220 - {formatCount(i18n, post.replyCount)} 221 - </PostControlButtonText> 222 - )} 223 - </PostControlButton> 224 - </View> 225 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 226 - <RepostButton 227 - isReposted={!!post.viewer?.repost} 228 - repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 229 - onRepost={onRepost} 230 - onQuote={onQuote} 231 - big={big} 232 - embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 233 - /> 234 - </View> 235 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 236 - <PostControlButton 237 - testID="likeBtn" 238 - big={big} 239 - onPress={() => requireAuth(() => onPressToggleLike())} 240 - label={ 241 - post.viewer?.like 242 - ? _( 243 - msg({ 244 - message: `Unlike (${plural(post.likeCount || 0, { 245 - one: '# like', 246 - other: '# likes', 247 - })})`, 248 - comment: 249 - 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 250 - }), 251 - ) 252 - : _( 253 - msg({ 254 - message: `Like (${plural(post.likeCount || 0, { 255 - one: '# like', 256 - other: '# likes', 257 - })})`, 258 - comment: 259 - 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 260 - }), 261 - ) 262 - }> 263 - <AnimatedLikeIcon 264 - isLiked={Boolean(post.viewer?.like)} 207 + <View style={[a.flex_row, a.flex_1, {maxWidth: 320}]}> 208 + <View 209 + style={[ 210 + a.flex_1, 211 + a.align_start, 212 + {marginLeft: big ? -2 : -6}, 213 + replyDisabled ? {opacity: 0.5} : undefined, 214 + ]}> 215 + <PostControlButton 216 + testID="replyBtn" 217 + onPress={ 218 + !replyDisabled 219 + ? () => requireAuth(() => onPressReply()) 220 + : undefined 221 + } 222 + label={_( 223 + msg({ 224 + message: `Reply (${plural(post.replyCount || 0, { 225 + one: '# reply', 226 + other: '# replies', 227 + })})`, 228 + comment: 229 + 'Accessibility label for the reply button, verb form followed by number of replies and noun form', 230 + }), 231 + )} 232 + big={big}> 233 + <PostControlButtonIcon icon={Bubble} /> 234 + {typeof post.replyCount !== 'undefined' && post.replyCount > 0 && ( 235 + <PostControlButtonText> 236 + {formatPostStatCount(post.replyCount)} 237 + </PostControlButtonText> 238 + )} 239 + </PostControlButton> 240 + </View> 241 + <View style={[a.flex_1, a.align_start]}> 242 + <RepostButton 243 + isReposted={!!post.viewer?.repost} 244 + repostCount={(post.repostCount ?? 0) + (post.quoteCount ?? 0)} 245 + onRepost={onRepost} 246 + onQuote={onQuote} 265 247 big={big} 266 - hasBeenToggled={hasLikeIconBeenToggled} 248 + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 267 249 /> 268 - <CountWheel 269 - likeCount={post.likeCount ?? 0} 250 + </View> 251 + <View style={[a.flex_1, a.align_start]}> 252 + <PostControlButton 253 + testID="likeBtn" 270 254 big={big} 271 - isLiked={Boolean(post.viewer?.like)} 272 - hasBeenToggled={hasLikeIconBeenToggled} 273 - /> 274 - </PostControlButton> 275 - </View> 276 - <View style={big ? a.align_center : [a.flex_1, a.align_start]}> 277 - <View style={[!big && a.ml_sm]}> 278 - <ShareMenuButton 279 - testID="postShareBtn" 280 - post={post} 281 - big={big} 282 - record={record} 283 - richText={richText} 284 - timestamp={post.indexedAt} 285 - threadgateRecord={threadgateRecord} 286 - onShare={onShare} 287 - /> 255 + onPress={() => requireAuth(() => onPressToggleLike())} 256 + label={ 257 + post.viewer?.like 258 + ? _( 259 + msg({ 260 + message: `Unlike (${plural(post.likeCount || 0, { 261 + one: '# like', 262 + other: '# likes', 263 + })})`, 264 + comment: 265 + 'Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun', 266 + }), 267 + ) 268 + : _( 269 + msg({ 270 + message: `Like (${plural(post.likeCount || 0, { 271 + one: '# like', 272 + other: '# likes', 273 + })})`, 274 + comment: 275 + 'Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form', 276 + }), 277 + ) 278 + }> 279 + <AnimatedLikeIcon 280 + isLiked={Boolean(post.viewer?.like)} 281 + big={big} 282 + hasBeenToggled={hasLikeIconBeenToggled} 283 + /> 284 + <CountWheel 285 + likeCount={post.likeCount ?? 0} 286 + big={big} 287 + isLiked={Boolean(post.viewer?.like)} 288 + hasBeenToggled={hasLikeIconBeenToggled} 289 + /> 290 + </PostControlButton> 288 291 </View> 292 + {/* Spacer! */} 293 + <View /> 289 294 </View> 290 - <View 291 - style={big ? a.align_center : [gtMobile && a.flex_1, a.align_start]}> 295 + <View style={[a.flex_row, a.justify_end, secondaryControlSpacingStyles]}> 296 + <BookmarkButton 297 + post={post} 298 + big={big} 299 + logContext={logContext} 300 + hitSlop={{ 301 + right: secondaryControlSpacingStyles.gap / 2, 302 + }} 303 + /> 304 + <ShareMenuButton 305 + testID="postShareBtn" 306 + post={post} 307 + big={big} 308 + record={record} 309 + richText={richText} 310 + timestamp={post.indexedAt} 311 + threadgateRecord={threadgateRecord} 312 + onShare={onShare} 313 + hitSlop={{ 314 + left: secondaryControlSpacingStyles.gap / 2, 315 + right: secondaryControlSpacingStyles.gap / 2, 316 + }} 317 + /> 292 318 <PostMenuButton 293 319 testID="postDropdownBtn" 294 320 post={post} ··· 300 326 timestamp={post.indexedAt} 301 327 threadgateRecord={threadgateRecord} 302 328 onShowLess={onShowLess} 329 + hitSlop={{ 330 + left: secondaryControlSpacingStyles.gap / 2, 331 + }} 303 332 /> 304 333 </View> 305 334 </View>
+48
src/components/PostControls/util.ts
··· 1 + import {useCallback} from 'react' 2 + import {msg} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + /** 6 + * This matches `formatCount` from `view/com/util/numeric/format.ts`, but has 7 + * additional truncation logic for large numbers. `roundingMode` should always 8 + * match the original impl, regardless of if we add more formatting here. 9 + */ 10 + export function useFormatPostStatCount() { 11 + const {i18n} = useLingui() 12 + 13 + return useCallback( 14 + (postStatCount: number) => { 15 + const isOver1k = postStatCount >= 1_000 16 + const isOver10k = postStatCount >= 10_000 17 + const isOver1M = postStatCount >= 1_000_000 18 + const formatted = i18n.number(postStatCount, { 19 + notation: 'compact', 20 + maximumFractionDigits: isOver10k ? 0 : 1, 21 + // @ts-expect-error - roundingMode not in the types 22 + roundingMode: 'trunc', 23 + }) 24 + const count = formatted.replace(/\D+$/g, '') 25 + 26 + if (isOver1M) { 27 + return i18n._( 28 + msg({ 29 + message: `${count}M`, 30 + comment: 31 + 'For post statistics. Indicates a number in the millions. Please use the shortest format appropriate for your language.', 32 + }), 33 + ) 34 + } else if (isOver1k) { 35 + return i18n._( 36 + msg({ 37 + message: `${count}K`, 38 + comment: 39 + 'For post statistics. Indicates a number in the thousands. Please use the shortest format appropriate for your language.', 40 + }), 41 + ) 42 + } else { 43 + return count 44 + } 45 + }, 46 + [i18n], 47 + ) 48 + }
+1 -1
src/components/StarterPack/ProfileStarterPacks.tsx
··· 214 214 onError: e => { 215 215 logger.error('Failed to generate starter pack', {safeMessage: e}) 216 216 setIsGenerating(false) 217 - if (e.name === 'NOT_ENOUGH_FOLLOWERS') { 217 + if (e.message.includes('NOT_ENOUGH_FOLLOWERS')) { 218 218 followersDialogControl.open() 219 219 } else { 220 220 errorDialogControl.open()
+247
src/components/WelcomeModal.tsx
··· 1 + import {useEffect, useState} from 'react' 2 + import {Pressable, View} from 'react-native' 3 + import {ImageBackground} from 'expo-image' 4 + import {msg, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {FocusGuards, FocusScope} from 'radix-ui/internal' 7 + 8 + import {logger} from '#/logger' 9 + import {useLoggedOutViewControls} from '#/state/shell/logged-out' 10 + import {Logo} from '#/view/icons/Logo' 11 + import {atoms as a, flatten, useBreakpoints, web} from '#/alf' 12 + import {Button, ButtonText} from '#/components/Button' 13 + import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 14 + import {Text} from '#/components/Typography' 15 + 16 + const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg') 17 + 18 + interface WelcomeModalProps { 19 + control: { 20 + isOpen: boolean 21 + open: () => void 22 + close: () => void 23 + } 24 + } 25 + 26 + export function WelcomeModal({control}: WelcomeModalProps) { 27 + const {_} = useLingui() 28 + const {requestSwitchToAccount} = useLoggedOutViewControls() 29 + const {gtMobile} = useBreakpoints() 30 + const [isExiting, setIsExiting] = useState(false) 31 + const [signInLinkHovered, setSignInLinkHovered] = useState(false) 32 + 33 + const fadeOutAndClose = (callback?: () => void) => { 34 + setIsExiting(true) 35 + setTimeout(() => { 36 + control.close() 37 + if (callback) callback() 38 + }, 150) 39 + } 40 + 41 + useEffect(() => { 42 + if (control.isOpen) { 43 + logger.metric('welcomeModal:presented', {}) 44 + } 45 + }, [control.isOpen]) 46 + 47 + const onPressCreateAccount = () => { 48 + logger.metric('welcomeModal:signupClicked', {}) 49 + control.close() 50 + requestSwitchToAccount({requestedAccount: 'new'}) 51 + } 52 + 53 + const onPressExplore = () => { 54 + logger.metric('welcomeModal:exploreClicked', {}) 55 + fadeOutAndClose() 56 + } 57 + 58 + const onPressSignIn = () => { 59 + logger.metric('welcomeModal:signinClicked', {}) 60 + control.close() 61 + requestSwitchToAccount({requestedAccount: 'existing'}) 62 + } 63 + 64 + FocusGuards.useFocusGuards() 65 + 66 + return ( 67 + <View 68 + role="dialog" 69 + aria-modal 70 + style={[ 71 + a.fixed, 72 + a.inset_0, 73 + a.justify_center, 74 + a.align_center, 75 + {zIndex: 9999, backgroundColor: 'rgba(0,0,0,0.2)'}, 76 + web({backdropFilter: 'blur(15px)'}), 77 + isExiting ? a.fade_out : a.fade_in, 78 + ]}> 79 + <FocusScope.FocusScope asChild loop trapped> 80 + <View 81 + style={flatten([ 82 + { 83 + maxWidth: 800, 84 + maxHeight: 600, 85 + width: '90%', 86 + height: '90%', 87 + backgroundColor: '#C0DCF0', 88 + }, 89 + a.rounded_lg, 90 + a.overflow_hidden, 91 + a.zoom_in, 92 + ])}> 93 + <ImageBackground 94 + source={welcomeModalBg} 95 + style={[a.flex_1, a.justify_center]} 96 + contentFit="cover"> 97 + <View style={[a.gap_2xl, a.align_center, a.p_4xl]}> 98 + <View 99 + style={[ 100 + a.flex_row, 101 + a.align_center, 102 + a.justify_center, 103 + a.w_full, 104 + a.p_0, 105 + ]}> 106 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 107 + <Logo width={26} /> 108 + <Text 109 + style={[ 110 + a.text_2xl, 111 + a.font_bold, 112 + a.user_select_none, 113 + {color: '#354358', letterSpacing: -0.5}, 114 + ]}> 115 + Bluesky 116 + </Text> 117 + </View> 118 + </View> 119 + <View 120 + style={[ 121 + a.gap_sm, 122 + a.align_center, 123 + a.pt_5xl, 124 + a.pb_3xl, 125 + a.mt_2xl, 126 + ]}> 127 + <Text 128 + style={[ 129 + gtMobile ? a.text_4xl : a.text_3xl, 130 + a.font_bold, 131 + a.text_center, 132 + {color: '#354358'}, 133 + web({ 134 + backgroundImage: 135 + 'linear-gradient(180deg, #313F54 0%, #667B99 83.65%, rgba(102, 123, 153, 0.50) 100%)', 136 + backgroundClip: 'text', 137 + WebkitBackgroundClip: 'text', 138 + WebkitTextFillColor: 'transparent', 139 + color: 'transparent', 140 + lineHeight: 1.2, 141 + letterSpacing: -0.5, 142 + }), 143 + ]}> 144 + <Trans>Real people.</Trans> 145 + {'\n'} 146 + <Trans>Real conversations.</Trans> 147 + {'\n'} 148 + <Trans>Social media you control.</Trans> 149 + </Text> 150 + </View> 151 + <View style={[a.gap_md, a.align_center]}> 152 + <View> 153 + <Button 154 + onPress={onPressCreateAccount} 155 + label={_(msg`Create account`)} 156 + size="large" 157 + color="primary" 158 + style={{ 159 + width: 200, 160 + backgroundColor: '#006AFF', 161 + }}> 162 + <ButtonText> 163 + <Trans>Create account</Trans> 164 + </ButtonText> 165 + </Button> 166 + <Button 167 + onPress={onPressExplore} 168 + label={_(msg`Explore the app`)} 169 + size="large" 170 + color="primary" 171 + variant="ghost" 172 + style={[a.bg_transparent, {width: 200}]} 173 + hoverStyle={[a.bg_transparent]}> 174 + {({hovered}) => ( 175 + <ButtonText 176 + style={[hovered && [a.underline], {color: '#006AFF'}]}> 177 + <Trans>Explore the app</Trans> 178 + </ButtonText> 179 + )} 180 + </Button> 181 + </View> 182 + <View style={[a.align_center, {minWidth: 200}]}> 183 + <Text 184 + style={[ 185 + a.text_md, 186 + a.text_center, 187 + {color: '#405168', lineHeight: 24}, 188 + ]}> 189 + <Trans>Already have an account?</Trans>{' '} 190 + <Pressable 191 + onPointerEnter={() => setSignInLinkHovered(true)} 192 + onPointerLeave={() => setSignInLinkHovered(false)} 193 + accessibilityRole="button" 194 + accessibilityLabel={_(msg`Sign in`)} 195 + accessibilityHint=""> 196 + <Text 197 + style={[ 198 + a.font_medium, 199 + { 200 + color: '#006AFF', 201 + fontSize: undefined, 202 + }, 203 + signInLinkHovered && a.underline, 204 + ]} 205 + onPress={onPressSignIn}> 206 + <Trans>Sign in</Trans> 207 + </Text> 208 + </Pressable> 209 + </Text> 210 + </View> 211 + </View> 212 + </View> 213 + <Button 214 + label={_(msg`Close welcome modal`)} 215 + style={[ 216 + a.absolute, 217 + { 218 + top: 8, 219 + right: 8, 220 + }, 221 + a.bg_transparent, 222 + ]} 223 + hoverStyle={[a.bg_transparent]} 224 + onPress={() => { 225 + logger.metric('welcomeModal:dismissed', {}) 226 + fadeOutAndClose() 227 + }} 228 + color="secondary" 229 + size="small" 230 + variant="ghost" 231 + shape="round"> 232 + {({hovered, pressed, focused}) => ( 233 + <XIcon 234 + size="md" 235 + style={{ 236 + color: '#354358', 237 + opacity: hovered || pressed || focused ? 1 : 0.7, 238 + }} 239 + /> 240 + )} 241 + </Button> 242 + </ImageBackground> 243 + </View> 244 + </FocusScope.FocusScope> 245 + </View> 246 + ) 247 + }
+49 -1
src/components/ageAssurance/AgeAssuranceAccountCard.tsx
··· 3 3 import {useLingui} from '@lingui/react' 4 4 5 5 import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' 6 + import {isNative} from '#/platform/detection' 6 7 import {useAgeAssurance} from '#/state/ageAssurance/useAgeAssurance' 7 8 import {logger} from '#/state/ageAssurance/util' 9 + import {useDeviceGeolocationApi} from '#/state/geolocation' 8 10 import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 9 11 import {Admonition} from '#/components/Admonition' 10 12 import {AgeAssuranceAppealDialog} from '#/components/ageAssurance/AgeAssuranceAppealDialog' ··· 16 18 import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 17 19 import {Button, ButtonText} from '#/components/Button' 18 20 import * as Dialog from '#/components/Dialog' 21 + import {DeviceLocationRequestDialog} from '#/components/dialogs/DeviceLocationRequestDialog' 19 22 import {Divider} from '#/components/Divider' 20 23 import {createStaticClick, InlineLinkText} from '#/components/Link' 24 + import * as Toast from '#/components/Toast' 21 25 import {Text} from '#/components/Typography' 22 26 23 27 export function AgeAssuranceAccountCard({style}: ViewStyleProp & {}) { ··· 35 39 const {_, i18n} = useLingui() 36 40 const control = useDialogControl() 37 41 const appealControl = Dialog.useDialogControl() 42 + const locationControl = Dialog.useDialogControl() 38 43 const getTimeAgo = useGetTimeAgo() 39 44 const {gtPhone} = useBreakpoints() 45 + const {setDeviceGeolocation} = useDeviceGeolocationApi() 40 46 41 47 const copy = useAgeAssuranceCopy() 42 48 const {status, lastInitiatedAt} = useAgeAssurance() ··· 71 77 </View> 72 78 </View> 73 79 74 - <View style={[a.pb_md]}> 80 + <View style={[a.pb_md, a.gap_xs]}> 75 81 <Text style={[a.text_sm, a.leading_snug]}>{copy.notice}</Text> 82 + 83 + {isNative && ( 84 + <> 85 + <Text style={[a.text_sm, a.leading_snug]}> 86 + <Trans> 87 + Is your location not accurate?{' '} 88 + <InlineLinkText 89 + label={_(msg`Confirm your location`)} 90 + {...createStaticClick(() => { 91 + locationControl.open() 92 + })}> 93 + Tap here to confirm your location. 94 + </InlineLinkText>{' '} 95 + </Trans> 96 + </Text> 97 + 98 + <DeviceLocationRequestDialog 99 + control={locationControl} 100 + onLocationAcquired={props => { 101 + if (props.geolocationStatus.isAgeRestrictedGeo) { 102 + props.disableDialogAction() 103 + props.setDialogError( 104 + _( 105 + msg`We're sorry, but based on your device's location, you are currently located in a region that requires age assurance.`, 106 + ), 107 + ) 108 + } else { 109 + props.closeDialog(() => { 110 + // set this after close! 111 + setDeviceGeolocation({ 112 + countryCode: props.geolocationStatus.countryCode, 113 + regionCode: props.geolocationStatus.regionCode, 114 + }) 115 + Toast.show(_(msg`Thanks! You're all set.`), { 116 + type: 'success', 117 + }) 118 + }) 119 + } 120 + }} 121 + /> 122 + </> 123 + )} 76 124 </View> 77 125 78 126 {isBlocked ? (
+4 -2
src/components/ageAssurance/AgeRestrictedScreen.tsx
··· 18 18 children, 19 19 screenTitle, 20 20 infoText, 21 + rightHeaderSlot, 21 22 }: { 22 23 children: React.ReactNode 23 24 screenTitle?: string 24 25 infoText?: string 26 + rightHeaderSlot?: React.ReactNode 25 27 }) { 26 28 const {_} = useLingui() 27 29 const copy = useAgeAssuranceCopy() ··· 46 48 <Layout.Screen> 47 49 <Layout.Header.Outer> 48 50 <Layout.Header.BackButton /> 49 - <Layout.Header.Content> 51 + <Layout.Header.Content align="left"> 50 52 <Layout.Header.TitleText> 51 53 {screenTitle ?? <Trans>Unavailable</Trans>} 52 54 </Layout.Header.TitleText> 53 55 </Layout.Header.Content> 54 - <Layout.Header.Slot /> 56 + {rightHeaderSlot ?? <Layout.Header.Slot />} 55 57 </Layout.Header.Outer> 56 58 <Layout.Content> 57 59 <View style={[a.p_lg]}>
+171
src/components/dialogs/DeviceLocationRequestDialog.tsx
··· 1 + import {useState} from 'react' 2 + import {View} from 'react-native' 3 + import {msg, Trans} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {wait} from '#/lib/async/wait' 7 + import {isNetworkError, useCleanError} from '#/lib/hooks/useCleanError' 8 + import {logger} from '#/logger' 9 + import {isWeb} from '#/platform/detection' 10 + import { 11 + computeGeolocationStatus, 12 + type GeolocationStatus, 13 + useGeolocationConfig, 14 + } from '#/state/geolocation' 15 + import {useRequestDeviceLocation} from '#/state/geolocation/useRequestDeviceLocation' 16 + import {atoms as a, useTheme, web} from '#/alf' 17 + import {Admonition} from '#/components/Admonition' 18 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19 + import * as Dialog from '#/components/Dialog' 20 + import {PinLocation_Stroke2_Corner0_Rounded as LocationIcon} from '#/components/icons/PinLocation' 21 + import {Loader} from '#/components/Loader' 22 + import {Text} from '#/components/Typography' 23 + 24 + export type Props = { 25 + onLocationAcquired?: (props: { 26 + geolocationStatus: GeolocationStatus 27 + setDialogError: (error: string) => void 28 + disableDialogAction: () => void 29 + closeDialog: (callback?: () => void) => void 30 + }) => void 31 + } 32 + 33 + export function DeviceLocationRequestDialog({ 34 + control, 35 + onLocationAcquired, 36 + }: Props & { 37 + control: Dialog.DialogOuterProps['control'] 38 + }) { 39 + const {_} = useLingui() 40 + return ( 41 + <Dialog.Outer control={control}> 42 + <Dialog.Handle /> 43 + 44 + <Dialog.ScrollableInner 45 + label={_(msg`Confirm your location`)} 46 + style={[web({maxWidth: 380})]}> 47 + <DeviceLocationRequestDialogInner 48 + onLocationAcquired={onLocationAcquired} 49 + /> 50 + <Dialog.Close /> 51 + </Dialog.ScrollableInner> 52 + </Dialog.Outer> 53 + ) 54 + } 55 + 56 + function DeviceLocationRequestDialogInner({onLocationAcquired}: Props) { 57 + const t = useTheme() 58 + const {_} = useLingui() 59 + const {close} = Dialog.useDialogContext() 60 + const requestDeviceLocation = useRequestDeviceLocation() 61 + const {config} = useGeolocationConfig() 62 + const cleanError = useCleanError() 63 + 64 + const [isRequesting, setIsRequesting] = useState(false) 65 + const [error, setError] = useState<string>('') 66 + const [dialogDisabled, setDialogDisabled] = useState(false) 67 + 68 + const onPressConfirm = async () => { 69 + setError('') 70 + setIsRequesting(true) 71 + 72 + try { 73 + const req = await wait(1e3, requestDeviceLocation()) 74 + 75 + if (req.granted) { 76 + const location = req.location 77 + 78 + if (location && location.countryCode) { 79 + const geolocationStatus = computeGeolocationStatus(location, config) 80 + onLocationAcquired?.({ 81 + geolocationStatus, 82 + setDialogError: setError, 83 + disableDialogAction: () => setDialogDisabled(true), 84 + closeDialog: close, 85 + }) 86 + } else { 87 + setError(_(msg`Failed to resolve location. Please try again.`)) 88 + } 89 + } else { 90 + setError( 91 + _( 92 + msg`Unable to access location. You'll need to visit your system settings to enable location services for Bluesky.`, 93 + ), 94 + ) 95 + } 96 + } catch (e: any) { 97 + const {clean, raw} = cleanError(e) 98 + setError(clean || raw || e.message) 99 + if (!isNetworkError(e)) { 100 + logger.error(`blockedGeoOverlay: unexpected error`, { 101 + safeMessage: e.message, 102 + }) 103 + } 104 + } finally { 105 + setIsRequesting(false) 106 + } 107 + } 108 + 109 + return ( 110 + <View style={[a.gap_md]}> 111 + <Text style={[a.text_xl, a.font_heavy]}> 112 + <Trans>Confirm your location</Trans> 113 + </Text> 114 + <View style={[a.gap_sm, a.pb_xs]}> 115 + <Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_medium]}> 116 + <Trans> 117 + Tap below to allow Bluesky to access your GPS location. We will then 118 + use that data to more accurately determine the content and features 119 + available in your region. 120 + </Trans> 121 + </Text> 122 + 123 + <Text 124 + style={[ 125 + a.text_md, 126 + a.leading_snug, 127 + t.atoms.text_contrast_medium, 128 + a.pb_xs, 129 + ]}> 130 + <Trans> 131 + Your location data is not tracked and does not leave your device. 132 + </Trans> 133 + </Text> 134 + </View> 135 + 136 + {error && ( 137 + <View style={[a.pb_xs]}> 138 + <Admonition type="error">{error}</Admonition> 139 + </View> 140 + )} 141 + 142 + <View style={[a.gap_sm]}> 143 + {!dialogDisabled && ( 144 + <Button 145 + disabled={isRequesting} 146 + label={_(msg`Allow location access`)} 147 + onPress={onPressConfirm} 148 + size={isWeb ? 'small' : 'large'} 149 + color="primary"> 150 + <ButtonIcon icon={isRequesting ? Loader : LocationIcon} /> 151 + <ButtonText> 152 + <Trans>Allow location access</Trans> 153 + </ButtonText> 154 + </Button> 155 + )} 156 + 157 + {!isWeb && ( 158 + <Button 159 + label={_(msg`Cancel`)} 160 + onPress={() => close()} 161 + size={isWeb ? 'small' : 'large'} 162 + color="secondary"> 163 + <ButtonText> 164 + <Trans>Cancel</Trans> 165 + </ButtonText> 166 + </Button> 167 + )} 168 + </View> 169 + </View> 170 + ) 171 + }
+181
src/components/dialogs/nuxs/BookmarksAnnouncement.tsx
··· 1 + import {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {Image} from 'expo-image' 4 + import {LinearGradient} from 'expo-linear-gradient' 5 + import {msg, Trans} from '@lingui/macro' 6 + import {useLingui} from '@lingui/react' 7 + 8 + import {isWeb} from '#/platform/detection' 9 + import {atoms as a, useTheme, web} from '#/alf' 10 + import {transparentifyColor} from '#/alf/util/colorGeneration' 11 + import {Button, ButtonText} from '#/components/Button' 12 + import * as Dialog from '#/components/Dialog' 13 + import {useNuxDialogContext} from '#/components/dialogs/nuxs' 14 + import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle' 15 + import {Text} from '#/components/Typography' 16 + 17 + export function BookmarksAnnouncement() { 18 + const t = useTheme() 19 + const {_} = useLingui() 20 + const nuxDialogs = useNuxDialogContext() 21 + const control = Dialog.useDialogControl() 22 + 23 + Dialog.useAutoOpen(control) 24 + 25 + const onClose = useCallback(() => { 26 + nuxDialogs.dismissActiveNux() 27 + }, [nuxDialogs]) 28 + 29 + return ( 30 + <Dialog.Outer 31 + control={control} 32 + onClose={onClose} 33 + nativeOptions={{preventExpansion: true}}> 34 + <Dialog.Handle /> 35 + 36 + <Dialog.ScrollableInner 37 + label={_(msg`Introducing saved posts AKA bookmarks`)} 38 + style={[web({maxWidth: 440})]} 39 + contentContainerStyle={[ 40 + { 41 + paddingTop: 0, 42 + paddingLeft: 0, 43 + paddingRight: 0, 44 + }, 45 + ]}> 46 + <View 47 + style={[ 48 + a.align_center, 49 + a.overflow_hidden, 50 + { 51 + gap: 16, 52 + paddingTop: isWeb ? 24 : 40, 53 + borderTopLeftRadius: a.rounded_md.borderRadius, 54 + borderTopRightRadius: a.rounded_md.borderRadius, 55 + }, 56 + ]}> 57 + <LinearGradient 58 + colors={[t.palette.primary_25, t.palette.primary_100]} 59 + locations={[0, 1]} 60 + start={{x: 0, y: 0}} 61 + end={{x: 0, y: 1}} 62 + style={[a.absolute, a.inset_0]} 63 + /> 64 + <View style={[a.flex_row, a.align_center, a.gap_xs]}> 65 + <SparkleIcon fill={t.palette.primary_800} size="sm" /> 66 + <Text 67 + style={[ 68 + a.font_bold, 69 + { 70 + color: t.palette.primary_800, 71 + }, 72 + ]}> 73 + <Trans>New Feature</Trans> 74 + </Text> 75 + </View> 76 + 77 + <View 78 + style={[ 79 + a.relative, 80 + a.w_full, 81 + { 82 + paddingTop: 8, 83 + paddingHorizontal: 32, 84 + paddingBottom: 32, 85 + }, 86 + ]}> 87 + <View 88 + style={[ 89 + { 90 + borderRadius: 24, 91 + aspectRatio: 333 / 104, 92 + }, 93 + isWeb 94 + ? [ 95 + { 96 + boxShadow: `0px 10px 15px -3px ${transparentifyColor(t.palette.black, 0.2)}`, 97 + }, 98 + ] 99 + : [ 100 + t.atoms.shadow_md, 101 + { 102 + shadowOpacity: 0.2, 103 + shadowOffset: { 104 + width: 0, 105 + height: 10, 106 + }, 107 + }, 108 + ], 109 + ]}> 110 + <Image 111 + accessibilityIgnoresInvertColors 112 + source={require('../../../../assets/images/bookmarks_announcement_nux.webp')} 113 + style={[ 114 + a.w_full, 115 + { 116 + aspectRatio: 333 / 104, 117 + }, 118 + ]} 119 + alt={_( 120 + msg({ 121 + message: `A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads "inventing a saturday that immediately follows monday".`, 122 + comment: 123 + 'Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English.', 124 + }), 125 + )} 126 + /> 127 + </View> 128 + </View> 129 + </View> 130 + <View style={[a.align_center, a.px_xl, a.pt_xl, a.gap_2xl, a.pb_sm]}> 131 + <View style={[a.gap_sm, a.align_center]}> 132 + <Text 133 + style={[ 134 + a.text_3xl, 135 + a.leading_tight, 136 + a.font_heavy, 137 + a.text_center, 138 + { 139 + fontSize: isWeb ? 28 : 32, 140 + maxWidth: 300, 141 + }, 142 + ]}> 143 + <Trans>Saved Posts</Trans> 144 + </Text> 145 + <Text 146 + style={[ 147 + a.text_md, 148 + a.leading_snug, 149 + a.text_center, 150 + { 151 + maxWidth: 340, 152 + }, 153 + ]}> 154 + <Trans> 155 + Finally! Keep track of posts that matter to you. Save them to 156 + revisit anytime. 157 + </Trans> 158 + </Text> 159 + </View> 160 + 161 + {!isWeb && ( 162 + <Button 163 + label={_(msg`Close`)} 164 + size="large" 165 + color="primary" 166 + onPress={() => { 167 + control.close() 168 + }} 169 + style={[a.w_full]}> 170 + <ButtonText> 171 + <Trans>Close</Trans> 172 + </ButtonText> 173 + </Button> 174 + )} 175 + </View> 176 + 177 + <Dialog.Close /> 178 + </Dialog.ScrollableInner> 179 + </Dialog.Outer> 180 + ) 181 + }
+3 -10
src/components/dialogs/nuxs/index.tsx
··· 12 12 import {useProfileQuery} from '#/state/queries/profile' 13 13 import {type SessionAccount, useSession} from '#/state/session' 14 14 import {useOnboardingState} from '#/state/shell' 15 - import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions' 15 + import {BookmarksAnnouncement} from '#/components/dialogs/nuxs/BookmarksAnnouncement' 16 16 /* 17 17 * NUXs 18 18 */ 19 19 import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' 20 - import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils' 21 20 22 21 type Context = { 23 22 activeNux: Nux | undefined ··· 34 33 }) => boolean 35 34 }[] = [ 36 35 { 37 - id: Nux.ActivitySubscriptions, 38 - enabled: ({currentProfile}) => { 39 - return isExistingUserAsOf( 40 - '2025-07-07T00:00:00.000Z', 41 - currentProfile.createdAt, 42 - ) 43 - }, 36 + id: Nux.BookmarksAnnouncement, 44 37 }, 45 38 ] 46 39 ··· 180 173 return ( 181 174 <Context.Provider value={ctx}> 182 175 {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} 183 - {activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />} 176 + {activeNux === Nux.BookmarksAnnouncement && <BookmarksAnnouncement />} 184 177 </Context.Provider> 185 178 ) 186 179 }
+3
src/components/hooks/useWelcomeModal.native.ts
··· 1 + export function useWelcomeModal() { 2 + throw new Error('useWelcomeModal is web only') 3 + }
+43
src/components/hooks/useWelcomeModal.ts
··· 1 + import {useEffect, useState} from 'react' 2 + 3 + import {isWeb} from '#/platform/detection' 4 + import {useSession} from '#/state/session' 5 + 6 + export function useWelcomeModal() { 7 + const {hasSession} = useSession() 8 + const [isOpen, setIsOpen] = useState(false) 9 + 10 + const open = () => setIsOpen(true) 11 + const close = () => { 12 + setIsOpen(false) 13 + // Mark that user has actively closed the modal, don't show again this session 14 + if (typeof window !== 'undefined') { 15 + sessionStorage.setItem('welcomeModalClosed', 'true') 16 + } 17 + } 18 + 19 + useEffect(() => { 20 + // Only show modal if: 21 + // 1. User is not logged in 22 + // 2. We're on the web (this is a web-only feature) 23 + // 3. We're on the homepage (path is '/' or '/home') 24 + // 4. User hasn't actively closed the modal in this session 25 + if (isWeb && !hasSession && typeof window !== 'undefined') { 26 + const currentPath = window.location.pathname 27 + const isHomePage = currentPath === '/' 28 + const hasUserClosedModal = 29 + sessionStorage.getItem('welcomeModalClosed') === 'true' 30 + 31 + if (isHomePage && !hasUserClosedModal) { 32 + // Small delay to ensure the page has loaded 33 + const timer = setTimeout(() => { 34 + open() 35 + }, 1000) 36 + 37 + return () => clearTimeout(timer) 38 + } 39 + } 40 + }, [hasSession]) 41 + 42 + return {isOpen, open, close} 43 + }
+16
src/components/icons/Bookmark.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + // custom, not part of icon library 4 + export const Bookmark = createSinglePathSVG({ 5 + path: 'M9.7 16.895a4 4 0 0 1 4.6 0l3.7 2.6V6.5a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v12.995l3.7-2.6Zm10.3 2.6c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v12.995Z', 6 + }) 7 + 8 + // custom, not part of icon library 9 + export const BookmarkFilled = createSinglePathSVG({ 10 + path: 'M16 2.5a4 4 0 0 1 4 4v12.995c0 1.62-1.825 2.567-3.15 1.636l-3.7-2.6a2.001 2.001 0 0 0-2.3 0l-3.7 2.6C5.825 22.062 4 21.115 4 19.495V6.5a4 4 0 0 1 4-4h8Z', 11 + }) 12 + 13 + // custom, not part of icon library, for LARGE (64px) size 14 + export const BookmarkDeleteLarge = createSinglePathSVG({ 15 + path: 'M14.2 2.625c.834 0 1.482 0 2.001.042.523.043.949.131 1.331.326.635.324 1.151.84 1.475 1.475.195.382.283.807.326 1.33.042.52.042 1.168.042 2.002v11.09c0 .495 0 .893-.027 1.199-.028.301-.087.585-.26.809-.249.323-.63.518-1.037.533-.282.01-.547-.107-.808-.26-.265-.154-.588-.385-.991-.673l-3.54-2.528c-.36-.258-.461-.322-.559-.347a.626.626 0 0 0-.306 0c-.098.025-.199.09-.559.347l-3.54 2.528c-.403.288-.726.519-.991.674-.261.152-.526.269-.808.259a1.376 1.376 0 0 1-1.038-.534c-.172-.223-.231-.507-.259-.808a7.31 7.31 0 0 1-.024-.528l-.003-.67V7.8c0-.834 0-1.482.042-2.001.043-.523.13-.949.325-1.331a3.376 3.376 0 0 1 1.476-1.475c.382-.195.808-.283 1.33-.326.52-.042 1.168-.042 2.002-.042h4.4Zm-4.4.75c-.846 0-1.458 0-1.94.04-.477.039-.792.114-1.051.246A2.626 2.626 0 0 0 5.66 4.81c-.132.259-.208.574-.247 1.051-.04.482-.039 1.094-.039 1.94v11.09l.003.658c.003.186.01.34.021.473.025.267.07.37.106.418a.626.626 0 0 0 .472.243c.059.002.168-.022.4-.158.23-.133.52-.34.935-.636l3.54-2.529c.308-.22.543-.396.81-.464.222-.056.454-.056.676 0 .267.068.5.244.81.464l3.54 2.529c.414.296.704.503.933.636.233.137.343.16.402.158a.626.626 0 0 0 .472-.243c.036-.048.081-.15.106-.419.024-.263.024-.62.024-1.13V7.8c0-.846 0-1.458-.04-1.94-.039-.477-.114-.792-.246-1.051A2.627 2.627 0 0 0 17.19 3.66c-.259-.132-.575-.207-1.051-.246-.482-.04-1.094-.04-1.94-.04H9.8Zm4.056 4.238a.375.375 0 0 1 .53.53L12.53 10l1.857 1.856a.375.375 0 0 1-.53.53L12 10.53l-1.856 1.857a.375.375 0 0 1-.53-.53L11.47 10 9.613 8.144a.375.375 0 0 1 .53-.53L12 9.47l1.856-1.857Z', 16 + })
+9
src/components/icons/PinLocation.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const PinLocation_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + path: 'M12 2a8 8 0 0 1 8 8c0 3.305-1.953 6.29-3.745 8.355a25.964 25.964 0 0 1-3.333 3.197c-.101.08-.181.142-.237.184l-.067.05-.018.014-.005.004-.002.002h-.001c-.003-.004-.042-.055-.592-.806l.592.807a1.001 1.001 0 0 1-1.184 0v-.001l-.003-.002-.005-.004-.018-.014-.067-.05a23.449 23.449 0 0 1-1.066-.877 25.973 25.973 0 0 1-2.504-2.503C5.953 16.29 4 13.305 4 10a8 8 0 0 1 8-8Zm0 2a6 6 0 0 0-6 6c0 2.56 1.547 5.076 3.255 7.044A23.978 23.978 0 0 0 12 19.723a23.976 23.976 0 0 0 2.745-2.679C16.453 15.076 18 12.56 18 10a6 6 0 0 0-6-6Zm-.002 2.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Zm0 2a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z', 5 + }) 6 + 7 + export const PinLocationFilled_Stroke2_Corner0_Rounded = createSinglePathSVG({ 8 + path: 'M12.591 21.806h.002l.001-.002.006-.004.018-.014a10.028 10.028 0 0 0 .304-.235 25.952 25.952 0 0 0 3.333-3.196C18.048 16.29 20 13.305 20 10a8 8 0 1 0-16 0c0 3.305 1.952 6.29 3.745 8.355a25.955 25.955 0 0 0 3.333 3.196 15.733 15.733 0 0 0 .304.235l.018.014.006.004.002.002a1 1 0 0 0 1.183 0Zm-.593-9.306a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z', 9 + })
+11
src/components/icons/Reply.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + // custom, off spec 4 + export const Reply = createSinglePathSVG({ 5 + path: 'M20.002 7a2 2 0 0 0-2-2h-12a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2a1 1 0 0 1 1 1v1.918l3.375-2.7a1 1 0 0 1 .625-.218h5a2 2 0 0 0 2-2V7Zm2 8a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z', 6 + }) 7 + 8 + // custom, off spec 9 + export const ReplyFilled = createSinglePathSVG({ 10 + path: 'M22.002 15a4 4 0 0 1-4 4h-4.648l-4.727 3.781A1.001 1.001 0 0 1 7.002 22v-3h-1a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h12a4 4 0 0 1 4 4v8Z', 11 + })
+13
src/env/common.ts
··· 93 93 process.env.EXPO_PUBLIC_GCP_PROJECT_ID === undefined 94 94 ? 0 95 95 : Number(process.env.EXPO_PUBLIC_GCP_PROJECT_ID) 96 + 97 + /** 98 + * URL for the bapp-config web worker _development_ environment. Can be a 99 + * locally running server, see `env.example` for more. 100 + */ 101 + export const BAPP_CONFIG_DEV_URL = process.env.BAPP_CONFIG_DEV_URL 102 + 103 + /** 104 + * Dev environment passthrough value for bapp-config web worker. Allows local 105 + * dev access to the web worker running in `development` mode. 106 + */ 107 + export const BAPP_CONFIG_DEV_BYPASS_SECRET: string = 108 + process.env.BAPP_CONFIG_DEV_BYPASS_SECRET
-1
src/lib/constants.ts
··· 113 113 export const HITSLOP_10 = createHitslop(10) 114 114 export const HITSLOP_20 = createHitslop(20) 115 115 export const HITSLOP_30 = createHitslop(30) 116 - export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 117 116 export const LANG_DROPDOWN_HITSLOP = {top: 10, bottom: 10, left: 4, right: 4} 118 117 export const BACK_HITSLOP = HITSLOP_30 119 118 export const MAX_POST_LINES = 25
+2 -2
src/lib/currency.ts
··· 1 1 import React from 'react' 2 2 3 3 import {deviceLocales} from '#/locale/deviceLocales' 4 - import {useGeolocation} from '#/state/geolocation' 4 + import {useGeolocationStatus} from '#/state/geolocation' 5 5 import {useLanguagePrefs} from '#/state/preferences' 6 6 7 7 /** ··· 275 275 export function useFormatCurrency( 276 276 options?: Parameters<typeof Intl.NumberFormat>[1], 277 277 ) { 278 - const {geolocation} = useGeolocation() 278 + const {location: geolocation} = useGeolocationStatus() 279 279 const {appLanguage} = useLanguagePrefs() 280 280 return React.useMemo(() => { 281 281 const locale = deviceLocales.at(0)
+4 -4
src/lib/custom-animations/CountWheel.tsx
··· 6 6 useReducedMotion, 7 7 withTiming, 8 8 } from 'react-native-reanimated' 9 - import {i18n} from '@lingui/core' 10 9 11 10 import {decideShouldRoll} from '#/lib/custom-animations/util' 12 11 import {s} from '#/lib/styles' 13 - import {formatCount} from '#/view/com/util/numeric/format' 14 12 import {Text} from '#/view/com/util/text/Text' 15 13 import {atoms as a, useTheme} from '#/alf' 14 + import {useFormatPostStatCount} from '#/components/PostControls/util' 16 15 17 16 const animationConfig = { 18 17 duration: 400, ··· 109 108 const [key, setKey] = React.useState(0) 110 109 const [prevCount, setPrevCount] = React.useState(likeCount) 111 110 const prevIsLiked = React.useRef(isLiked) 112 - const formattedCount = formatCount(i18n, likeCount) 113 - const formattedPrevCount = formatCount(i18n, prevCount) 111 + const formatPostStatCount = useFormatPostStatCount() 112 + const formattedCount = formatPostStatCount(likeCount) 113 + const formattedPrevCount = formatPostStatCount(prevCount) 114 114 115 115 React.useEffect(() => { 116 116 if (isLiked === prevIsLiked.current) {
+4 -4
src/lib/custom-animations/CountWheel.web.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 3 import {useReducedMotion} from 'react-native-reanimated' 4 - import {i18n} from '@lingui/core' 5 4 6 5 import {decideShouldRoll} from '#/lib/custom-animations/util' 7 6 import {s} from '#/lib/styles' 8 - import {formatCount} from '#/view/com/util/numeric/format' 9 7 import {Text} from '#/view/com/util/text/Text' 10 8 import {atoms as a, useTheme} from '#/alf' 9 + import {useFormatPostStatCount} from '#/components/PostControls/util' 11 10 12 11 const animationConfig = { 13 12 duration: 400, ··· 55 54 56 55 const [prevCount, setPrevCount] = React.useState(likeCount) 57 56 const prevIsLiked = React.useRef(isLiked) 58 - const formattedCount = formatCount(i18n, likeCount) 59 - const formattedPrevCount = formatCount(i18n, prevCount) 57 + const formatPostStatCount = useFormatPostStatCount() 58 + const formattedCount = formatPostStatCount(likeCount) 59 + const formattedPrevCount = formatPostStatCount(prevCount) 60 60 61 61 React.useEffect(() => { 62 62 if (isLiked === prevIsLiked.current) {
+1
src/lib/hooks/useNavigationTabState.ts
··· 9 9 isAtSearch: getTabState(state, 'Search') !== TabState.Outside, 10 10 // FeedsTab no longer exists, but this check works for `Feeds` screen as well 11 11 isAtFeeds: getTabState(state, 'Feeds') !== TabState.Outside, 12 + isAtBookmarks: getTabState(state, 'Bookmarks') !== TabState.Outside, 12 13 isAtNotifications: 13 14 getTabState(state, 'Notifications') !== TabState.Outside, 14 15 isAtMyProfile: getTabState(state, 'MyProfile') !== TabState.Outside,
+3
src/lib/hooks/useWebMediaQueries.tsx
··· 2 2 3 3 import {isNative} from '#/platform/detection' 4 4 5 + /** 6 + * @deprecated use `useBreakpoints` from `#/alf` instead 7 + */ 5 8 export function useWebMediaQueries() { 6 9 const isDesktop = useMediaQuery({minWidth: 1300}) 7 10 const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
+2 -1
src/lib/notifications/notifications.ts
··· 11 11 import {useAgeAssuranceContext} from '#/state/ageAssurance' 12 12 import {type SessionAccount, useAgent, useSession} from '#/state/session' 13 13 import BackgroundNotificationHandler from '#/../modules/expo-background-notification-handler' 14 + import {IS_DEV} from '#/env' 14 15 15 16 /** 16 17 * @private ··· 129 130 }: { 130 131 isAgeRestricted?: boolean 131 132 } = {}) => { 132 - if (!isNative) return 133 + if (!isNative || IS_DEV) return 133 134 134 135 /** 135 136 * This will also fire the listener added via `addPushTokenListener`. That
+1
src/lib/routes/types.ts
··· 86 86 } 87 87 StarterPackEdit: {rkey?: string} 88 88 VideoFeed: VideoFeedSourceContext 89 + Bookmarks: undefined 89 90 } 90 91 91 92 export type BottomTabNavigatorParams = CommonNavigatorParams & {
+1
src/lib/statsig/gates.ts
··· 15 15 | 'remove_show_latest_button' 16 16 | 'test_gate_1' 17 17 | 'test_gate_2' 18 + | 'welcome_modal'
+387 -218
src/locale/locales/en/messages.po
··· 76 76 77 77 #: src/view/shell/bottom-bar/BottomBar.tsx:225 78 78 #: src/view/shell/bottom-bar/BottomBar.tsx:257 79 - #: src/view/shell/Drawer.tsx:487 79 + #: src/view/shell/Drawer.tsx:498 80 80 msgid "{0, plural, one {# unread item} other {# unread items}}" 81 81 msgstr "" 82 82 ··· 95 95 msgid "{0, plural, one {following} other {following}}" 96 96 msgstr "" 97 97 98 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:473 98 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:479 99 99 msgid "{0, plural, one {like} other {likes}}" 100 100 msgstr "" 101 101 ··· 103 103 msgid "{0, plural, one {post} other {posts}}" 104 104 msgstr "" 105 105 106 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:457 106 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:463 107 107 msgid "{0, plural, one {quote} other {quotes}}" 108 108 msgstr "" 109 109 110 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:439 110 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:445 111 111 msgid "{0, plural, one {repost} other {reposts}}" 112 + msgstr "" 113 + 114 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:491 115 + msgid "{0, plural, one {save} other {saves}}" 112 116 msgstr "" 113 117 114 118 #: src/screens/Search/modules/ExploreTrendingTopics.tsx:82 ··· 212 216 msgid "{0}s" 213 217 msgstr "" 214 218 215 - #: src/view/shell/desktop/LeftNav.tsx:454 219 + #: src/view/shell/desktop/LeftNav.tsx:455 216 220 msgid "{count, plural, one {# unread item} other {# unread items}}" 217 221 msgstr "" 218 222 223 + #. For post statistics. Indicates a number in the thousands. Please use the shortest format appropriate for your language. 224 + #: src/components/PostControls/util.ts:36 225 + msgid "{count}K" 226 + msgstr "" 227 + 228 + #. For post statistics. Indicates a number in the millions. Please use the shortest format appropriate for your language. 229 + #: src/components/PostControls/util.ts:28 230 + msgid "{count}M" 231 + msgstr "" 232 + 219 233 #: src/screens/Profile/Header/EditProfileDialog.tsx:385 220 234 msgid "{DESCRIPTION_MAX_GRAPHEMES, plural, other {Description is too long. The maximum number of characters is #.}}" 221 235 msgstr "" ··· 464 478 msgid "<0>{0}, </0><1>{1}, </1>and {2, plural, one {# other} other {# others}} are included in your starter pack" 465 479 msgstr "" 466 480 467 - #: src/view/shell/Drawer.tsx:116 481 + #: src/view/shell/Drawer.tsx:117 468 482 msgid "<0>{0}</0> {1, plural, one {follower} other {followers}}" 469 483 msgstr "" 470 484 471 - #: src/view/shell/Drawer.tsx:127 485 + #: src/view/shell/Drawer.tsx:128 472 486 msgid "<0>{0}</0> {1, plural, one {following} other {following}}" 473 487 msgstr "" 474 488 ··· 530 544 msgid "A new form of verification" 531 545 msgstr "" 532 546 533 - #: src/components/BlockedGeoOverlay.tsx:39 547 + #: src/components/BlockedGeoOverlay.tsx:50 534 548 msgid "A new Mississippi law requires us to implement age verification for all users before they can access Bluesky. We think this law creates challenges that go beyond its child safety goals, and creates significant barriers that limit free speech and disproportionately harm smaller platforms and emerging technologies." 535 549 msgstr "" 536 550 551 + #. Contains a post that originally appeared in English. Consider translating the post text if it makes sense in your language, and noting that the post was translated from English. 552 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:120 553 + msgid "A screenshot of a post with a new button next to the share button that allows you to save the post to your bookmarks. The post is from @jcsalterego.bsky.social and reads \"inventing a saturday that immediately follows monday\"." 554 + msgstr "" 555 + 537 556 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:113 538 557 msgid "A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature." 539 558 msgstr "" 540 559 541 - #: src/Navigation.tsx:523 560 + #: src/Navigation.tsx:524 542 561 #: src/screens/Settings/AboutSettings.tsx:75 543 562 #: src/screens/Settings/Settings.tsx:244 544 563 #: src/screens/Settings/Settings.tsx:247 ··· 565 584 msgid "Accessibility" 566 585 msgstr "" 567 586 568 - #: src/Navigation.tsx:382 587 + #: src/Navigation.tsx:383 569 588 msgid "Accessibility Settings" 570 589 msgstr "" 571 590 572 - #: src/Navigation.tsx:398 591 + #: src/Navigation.tsx:399 573 592 #: src/screens/Login/LoginForm.tsx:194 574 593 #: src/screens/Settings/AccountSettings.tsx:51 575 594 #: src/screens/Settings/Settings.tsx:174 ··· 639 658 msgid "Activity from others" 640 659 msgstr "" 641 660 642 - #: src/Navigation.tsx:491 661 + #: src/Navigation.tsx:492 643 662 msgid "Activity notifications" 644 663 msgstr "" 645 664 ··· 694 713 695 714 #: src/screens/Settings/Settings.tsx:564 696 715 #: src/screens/Settings/Settings.tsx:567 697 - #: src/view/shell/desktop/LeftNav.tsx:261 698 - #: src/view/shell/desktop/LeftNav.tsx:265 716 + #: src/view/shell/desktop/LeftNav.tsx:262 717 + #: src/view/shell/desktop/LeftNav.tsx:266 699 718 msgid "Add another account" 700 719 msgstr "" 701 720 ··· 777 796 msgid "Add to lists" 778 797 msgstr "" 779 798 799 + #: src/components/PostControls/BookmarkButton.tsx:126 800 + msgid "Add to saved posts" 801 + msgstr "" 802 + 780 803 #: src/components/dialogs/StarterPackDialog.tsx:176 781 804 #: src/view/com/profile/ProfileMenu.tsx:308 782 805 #: src/view/com/profile/ProfileMenu.tsx:311 ··· 845 868 msgid "Age assurance inquiry was submitted" 846 869 msgstr "" 847 870 848 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:145 871 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:193 849 872 msgid "Age assurance only takes a few minutes" 850 873 msgstr "" 851 874 ··· 879 902 msgid "Allow access to your direct messages" 880 903 msgstr "" 881 904 882 - #: src/screens/Messages/Settings.tsx:86 883 - #: src/screens/Messages/Settings.tsx:89 905 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:146 906 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:152 907 + msgid "Allow location access" 908 + msgstr "" 909 + 910 + #: src/screens/Messages/Settings.tsx:75 911 + #: src/screens/Messages/Settings.tsx:78 884 912 msgid "Allow new messages from" 885 913 msgstr "" 886 914 ··· 905 933 #: src/screens/Settings/components/ChangePasswordDialog.tsx:235 906 934 #: src/screens/Settings/components/ChangePasswordDialog.tsx:241 907 935 msgid "Already have a code?" 936 + msgstr "" 937 + 938 + #: src/components/WelcomeModal.tsx:189 939 + msgid "Already have an account?" 908 940 msgstr "" 909 941 910 942 #: src/screens/Login/ChooseAccountForm.tsx:43 ··· 944 976 msgid "An email has been sent to {0}. It includes a confirmation code which you can enter below." 945 977 msgstr "" 946 978 947 - #: src/components/dialogs/GifSelect.tsx:264 979 + #: src/components/dialogs/GifSelect.tsx:253 948 980 #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:362 949 981 msgid "An error has occurred" 950 982 msgstr "" ··· 1047 1079 msgid "Animated GIF" 1048 1080 msgstr "" 1049 1081 1050 - #: src/components/BlockedGeoOverlay.tsx:92 1082 + #: src/components/BlockedGeoOverlay.tsx:104 1051 1083 #: src/components/PolicyUpdateOverlay/Badge.tsx:33 1052 1084 msgid "Announcement" 1053 1085 msgstr "" ··· 1071 1103 msgid "Anyone who follows me" 1072 1104 msgstr "" 1073 1105 1074 - #: src/Navigation.tsx:531 1106 + #: src/Navigation.tsx:532 1075 1107 #: src/screens/Settings/AppIconSettings/index.tsx:67 1076 1108 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:18 1077 1109 #: src/screens/Settings/AppIconSettings/SettingsListItem.tsx:23 ··· 1108 1140 msgid "App passwords" 1109 1141 msgstr "" 1110 1142 1111 - #: src/Navigation.tsx:350 1143 + #: src/Navigation.tsx:351 1112 1144 #: src/screens/Settings/AppPasswords.tsx:51 1113 1145 msgid "App Passwords" 1114 1146 msgstr "" ··· 1144 1176 msgid "Appeal this decision" 1145 1177 msgstr "" 1146 1178 1147 - #: src/Navigation.tsx:390 1179 + #: src/Navigation.tsx:391 1148 1180 #: src/screens/Settings/AppearanceSettings.tsx:86 1149 1181 #: src/screens/Settings/Settings.tsx:212 1150 1182 #: src/screens/Settings/Settings.tsx:215 ··· 1161 1193 msgid "Apply Pull Request" 1162 1194 msgstr "" 1163 1195 1164 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:643 1196 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:665 1165 1197 msgid "Archived from {0}" 1166 1198 msgstr "" 1167 1199 1168 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:612 1169 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:651 1200 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:634 1201 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:673 1170 1202 msgid "Archived post" 1171 1203 msgstr "" 1172 1204 ··· 1223 1255 msgid "Artistic or non-erotic nudity." 1224 1256 msgstr "" 1225 1257 1226 - #: src/components/BlockedGeoOverlay.tsx:42 1258 + #: src/components/BlockedGeoOverlay.tsx:53 1227 1259 msgid "As a small team, we cannot justify building the expensive infrastructure this requirement demands while legal challenges to this law are pending." 1228 1260 msgstr "" 1229 1261 ··· 1300 1332 1301 1333 #: src/components/dms/dialogs/NewChatDialog.tsx:54 1302 1334 #: src/components/dms/MessageProfileButton.tsx:58 1303 - #: src/screens/Messages/ChatList.tsx:358 1335 + #: src/screens/Messages/ChatList.tsx:369 1304 1336 #: src/screens/Messages/Conversation.tsx:228 1305 1337 msgid "Before you can message another user, you must first verify your email." 1306 1338 msgstr "" ··· 1385 1417 msgid "Blocked accounts" 1386 1418 msgstr "" 1387 1419 1388 - #: src/Navigation.tsx:191 1420 + #: src/Navigation.tsx:192 1389 1421 #: src/view/screens/ModerationBlockedAccounts.tsx:104 1390 1422 msgid "Blocked Accounts" 1391 1423 msgstr "" ··· 1420 1452 msgid "Bluesky" 1421 1453 msgstr "" 1422 1454 1423 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:668 1455 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:690 1424 1456 msgid "Bluesky cannot confirm the authenticity of the claimed date." 1425 1457 msgstr "" 1426 1458 ··· 1575 1607 #: src/components/activity-notifications/SubscribeProfileDialog.tsx:206 1576 1608 #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:129 1577 1609 #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:135 1610 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:159 1611 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:164 1578 1612 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:125 1579 1613 #: src/components/dialogs/EmailDialog/screens/Manage2FA/Enable.tsx:131 1580 1614 #: src/components/dialogs/InAppBrowserConsent.tsx:98 ··· 1582 1616 #: src/components/live/GoLiveDialog.tsx:247 1583 1617 #: src/components/live/GoLiveDialog.tsx:253 1584 1618 #: src/components/Menu/index.tsx:350 1585 - #: src/components/PostControls/RepostButton.tsx:209 1619 + #: src/components/PostControls/RepostButton.tsx:210 1586 1620 #: src/components/Prompt.tsx:144 1587 1621 #: src/components/Prompt.tsx:146 1588 1622 #: src/screens/Deactivated.tsx:158 ··· 1604 1638 #: src/view/com/composer/photos/EditImageDialog.web.tsx:52 1605 1639 #: src/view/com/modals/CreateOrEditList.tsx:333 1606 1640 #: src/view/com/modals/CropImage.web.tsx:97 1607 - #: src/view/shell/desktop/LeftNav.tsx:212 1641 + #: src/view/shell/desktop/LeftNav.tsx:213 1608 1642 msgid "Cancel" 1609 1643 msgstr "" 1610 1644 ··· 1624 1658 msgid "Cancel image crop" 1625 1659 msgstr "" 1626 1660 1627 - #: src/components/PostControls/RepostButton.tsx:203 1661 + #: src/components/PostControls/RepostButton.tsx:204 1628 1662 msgid "Cancel quote post" 1629 1663 msgstr "" 1630 1664 ··· 1636 1670 msgid "Cancel search" 1637 1671 msgstr "" 1638 1672 1639 - #: src/components/PostControls/index.tsx:101 1640 - #: src/components/PostControls/index.tsx:132 1641 - #: src/components/PostControls/index.tsx:160 1673 + #: src/components/PostControls/index.tsx:105 1674 + #: src/components/PostControls/index.tsx:136 1675 + #: src/components/PostControls/index.tsx:164 1642 1676 #: src/state/shell/composer/index.tsx:94 1643 1677 msgid "Cannot interact with a blocked user" 1644 1678 msgstr "" ··· 1713 1747 msgstr "" 1714 1748 1715 1749 #: src/lib/hooks/useNotificationHandler.ts:99 1716 - #: src/Navigation.tsx:548 1750 + #: src/Navigation.tsx:549 1717 1751 #: src/view/shell/bottom-bar/BottomBar.tsx:221 1718 - #: src/view/shell/desktop/LeftNav.tsx:606 1719 - #: src/view/shell/Drawer.tsx:455 1752 + #: src/view/shell/desktop/LeftNav.tsx:607 1753 + #: src/view/shell/Drawer.tsx:466 1720 1754 msgid "Chat" 1721 1755 msgstr "" 1722 1756 ··· 1739 1773 msgid "Chat muted" 1740 1774 msgstr "" 1741 1775 1742 - #: src/Navigation.tsx:558 1776 + #: src/Navigation.tsx:559 1743 1777 #: src/screens/Messages/components/InboxPreview.tsx:22 1744 1778 msgid "Chat request inbox" 1745 1779 msgstr "" ··· 1751 1785 msgstr "" 1752 1786 1753 1787 #: src/components/dms/ConvoMenu.tsx:76 1754 - #: src/Navigation.tsx:553 1755 - #: src/screens/Messages/ChatList.tsx:367 1788 + #: src/Navigation.tsx:554 1789 + #: src/screens/Messages/ChatList.tsx:81 1790 + #: src/screens/Messages/ChatList.tsx:85 1791 + #: src/screens/Messages/ChatList.tsx:378 1756 1792 msgid "Chat settings" 1757 1793 msgstr "" 1758 1794 1759 - #: src/screens/Messages/Settings.tsx:33 1760 - #: src/screens/Messages/Settings.tsx:78 1795 + #: src/screens/Messages/Settings.tsx:67 1761 1796 msgid "Chat Settings" 1762 1797 msgstr "" 1763 1798 ··· 1767 1802 msgstr "" 1768 1803 1769 1804 #: src/screens/Messages/ChatList.tsx:76 1770 - #: src/screens/Messages/ChatList.tsx:383 1771 - #: src/screens/Messages/ChatList.tsx:407 1805 + #: src/screens/Messages/ChatList.tsx:394 1806 + #: src/screens/Messages/ChatList.tsx:418 1772 1807 msgid "Chats" 1773 1808 msgstr "" 1774 1809 ··· 1890 1925 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:184 1891 1926 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:237 1892 1927 #: src/components/ageAssurance/AgeAssuranceRedirectDialog.tsx:243 1893 - #: src/components/dialogs/GifSelect.tsx:280 1928 + #: src/components/dialogs/GifSelect.tsx:269 1894 1929 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:158 1895 1930 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:167 1931 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:163 1932 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:171 1896 1933 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:178 1897 1934 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:187 1898 1935 #: src/components/dialogs/SearchablePeopleList.tsx:295 ··· 1935 1972 1936 1973 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:224 1937 1974 #: src/components/ageAssurance/AgeAssuranceInitDialog.tsx:230 1938 - #: src/components/dialogs/GifSelect.tsx:274 1975 + #: src/components/dialogs/GifSelect.tsx:263 1939 1976 #: src/components/verification/VerificationsDialog.tsx:136 1940 1977 #: src/components/verification/VerifierDialog.tsx:141 1941 1978 #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:246 ··· 1944 1981 msgid "Close dialog" 1945 1982 msgstr "" 1946 1983 1947 - #: src/view/shell/index.web.tsx:100 1984 + #: src/view/shell/index.web.tsx:110 1948 1985 msgid "Close drawer menu" 1949 1986 msgstr "" 1950 1987 ··· 1953 1990 msgid "Close emoji picker" 1954 1991 msgstr "" 1955 1992 1956 - #: src/components/dialogs/GifSelect.tsx:170 1993 + #: src/components/dialogs/GifSelect.tsx:159 1957 1994 msgid "Close GIF dialog" 1958 1995 msgstr "" 1959 1996 ··· 1973 2010 1974 2011 #: src/components/Menu/index.tsx:344 1975 2012 msgid "Close this dialog" 2013 + msgstr "" 2014 + 2015 + #: src/components/WelcomeModal.tsx:214 2016 + msgid "Close welcome modal" 1976 2017 msgstr "" 1977 2018 1978 2019 #: src/screens/Login/PasswordUpdatedForm.tsx:32 ··· 2020 2061 msgstr "" 2021 2062 2022 2063 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:45 2023 - #: src/Navigation.tsx:340 2064 + #: src/Navigation.tsx:341 2024 2065 #: src/view/screens/CommunityGuidelines.tsx:34 2025 2066 msgid "Community Guidelines" 2026 2067 msgstr "" ··· 2034 2075 msgid "Complete the challenge" 2035 2076 msgstr "" 2036 2077 2037 - #: src/view/shell/desktop/LeftNav.tsx:571 2078 + #: src/view/shell/desktop/LeftNav.tsx:572 2038 2079 msgid "Compose new post" 2039 2080 msgstr "" 2040 2081 ··· 2081 2122 msgid "Confirm your birthdate" 2082 2123 msgstr "" 2083 2124 2125 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:89 2126 + #: src/components/BlockedGeoOverlay.tsx:141 2127 + #: src/components/BlockedGeoOverlay.tsx:147 2128 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:45 2129 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:112 2130 + msgid "Confirm your location" 2131 + msgstr "" 2132 + 2133 + #: src/components/BlockedGeoOverlay.tsx:135 2134 + msgid "Confirm your location with GPS. Your location data is not tracked and does not leave your device." 2135 + msgstr "" 2136 + 2084 2137 #: src/components/dialogs/EmailDialog/components/TokenField.tsx:36 2085 2138 #: src/screens/Login/LoginForm.tsx:274 2086 2139 #: src/screens/Settings/components/ChangePasswordDialog.tsx:186 ··· 2100 2153 msgid "Connection issue" 2101 2154 msgstr "" 2102 2155 2103 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:84 2156 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:132 2104 2157 #: src/components/ageAssurance/AgeAssuranceAppealDialog.tsx:28 2105 2158 msgid "Contact our moderation team" 2106 2159 msgstr "" ··· 2124 2177 msgid "Content and media" 2125 2178 msgstr "" 2126 2179 2127 - #: src/Navigation.tsx:507 2180 + #: src/Navigation.tsx:508 2128 2181 msgid "Content and Media" 2129 2182 msgstr "" 2130 2183 ··· 2325 2378 2326 2379 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:40 2327 2380 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:107 2328 - #: src/Navigation.tsx:345 2381 + #: src/Navigation.tsx:346 2329 2382 #: src/view/screens/CopyrightPolicy.tsx:31 2330 2383 msgid "Copyright Policy" 2331 2384 msgstr "" ··· 2385 2438 2386 2439 #: src/components/StarterPack/ProfileStarterPacks.tsx:178 2387 2440 #: src/components/StarterPack/ProfileStarterPacks.tsx:287 2388 - #: src/Navigation.tsx:588 2441 + #: src/Navigation.tsx:589 2389 2442 msgid "Create a starter pack" 2390 2443 msgstr "" 2391 2444 ··· 2395 2448 2396 2449 #: src/components/LoggedOutCTA.tsx:71 2397 2450 #: src/components/LoggedOutCTA.tsx:76 2451 + #: src/components/WelcomeModal.tsx:155 2452 + #: src/components/WelcomeModal.tsx:163 2398 2453 #: src/view/com/auth/SplashScreen.tsx:55 2399 2454 #: src/view/com/auth/SplashScreen.web.tsx:117 2400 2455 #: src/view/shell/bottom-bar/BottomBar.tsx:345 ··· 2695 2750 #: src/lib/moderation/useLabelBehaviorDescription.ts:32 2696 2751 #: src/lib/moderation/useLabelBehaviorDescription.ts:42 2697 2752 #: src/lib/moderation/useLabelBehaviorDescription.ts:68 2698 - #: src/screens/Messages/Settings.tsx:155 2699 - #: src/screens/Messages/Settings.tsx:158 2753 + #: src/screens/Messages/Settings.tsx:144 2754 + #: src/screens/Messages/Settings.tsx:147 2700 2755 #: src/screens/Moderation/index.tsx:413 2701 2756 msgid "Disabled" 2702 2757 msgstr "" ··· 2954 3009 msgid "Edit Moderation List" 2955 3010 msgstr "" 2956 3011 2957 - #: src/Navigation.tsx:355 3012 + #: src/Navigation.tsx:356 2958 3013 #: src/view/screens/Feeds.tsx:518 2959 3014 msgid "Edit My Feeds" 2960 3015 msgstr "" ··· 2996 3051 msgid "Edit who can reply" 2997 3052 msgstr "" 2998 3053 2999 - #: src/Navigation.tsx:593 3054 + #: src/Navigation.tsx:594 3000 3055 msgid "Edit your starter pack" 3001 3056 msgstr "" 3002 3057 ··· 3116 3171 msgid "Enable trending videos in your Discover feed" 3117 3172 msgstr "" 3118 3173 3119 - #: src/screens/Messages/Settings.tsx:146 3120 - #: src/screens/Messages/Settings.tsx:149 3174 + #: src/screens/Messages/Settings.tsx:135 3175 + #: src/screens/Messages/Settings.tsx:138 3121 3176 #: src/screens/Moderation/index.tsx:411 3122 3177 msgid "Enabled" 3123 3178 msgstr "" ··· 3231 3286 msgid "Everybody can reply to this post." 3232 3287 msgstr "" 3233 3288 3234 - #: src/screens/Messages/Settings.tsx:99 3235 - #: src/screens/Messages/Settings.tsx:102 3289 + #: src/screens/Messages/Settings.tsx:88 3290 + #: src/screens/Messages/Settings.tsx:91 3236 3291 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:164 3237 3292 #: src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx:174 3238 3293 msgid "Everyone" ··· 3331 3386 msgid "Explicit sexual images." 3332 3387 msgstr "" 3333 3388 3334 - #: src/Navigation.tsx:750 3389 + #: src/Navigation.tsx:759 3335 3390 #: src/screens/Search/Shell.tsx:307 3336 - #: src/view/shell/desktop/LeftNav.tsx:688 3337 - #: src/view/shell/Drawer.tsx:403 3391 + #: src/view/shell/desktop/LeftNav.tsx:689 3392 + #: src/view/shell/Drawer.tsx:414 3338 3393 msgid "Explore" 3339 3394 msgstr "" 3340 3395 3396 + #: src/components/WelcomeModal.tsx:168 3397 + #: src/components/WelcomeModal.tsx:177 3398 + msgid "Explore the app" 3399 + msgstr "" 3400 + 3341 3401 #: src/screens/Settings/AccountSettings.tsx:152 3342 3402 #: src/screens/Settings/AccountSettings.tsx:156 3343 3403 msgid "Export my data" ··· 3362 3422 msgid "External media may allow websites to collect information about you and your device. No information is sent or requested until you press the \"play\" button." 3363 3423 msgstr "" 3364 3424 3365 - #: src/Navigation.tsx:374 3425 + #: src/Navigation.tsx:375 3366 3426 #: src/screens/Settings/ExternalMediaPreferences.tsx:34 3367 3427 msgid "External Media Preferences" 3368 3428 msgstr "" ··· 3424 3484 msgid "Failed to follow all suggested accounts, please try again" 3425 3485 msgstr "" 3426 3486 3427 - #: src/screens/Messages/ChatList.tsx:270 3487 + #: src/screens/Messages/ChatList.tsx:281 3428 3488 #: src/screens/Messages/Inbox.tsx:208 3429 3489 msgid "Failed to load conversations" 3430 3490 msgstr "" ··· 3436 3496 msgid "Failed to load feeds preferences" 3437 3497 msgstr "" 3438 3498 3439 - #: src/components/dialogs/GifSelect.tsx:224 3499 + #: src/components/dialogs/GifSelect.tsx:213 3440 3500 msgid "Failed to load GIFs" 3441 3501 msgstr "" 3442 3502 ··· 3495 3555 msgid "Failed to remove verification" 3496 3556 msgstr "" 3497 3557 3558 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:87 3559 + msgid "Failed to resolve location. Please try again." 3560 + msgstr "" 3561 + 3498 3562 #: src/lib/media/save-image.ts:28 3499 3563 msgid "Failed to save image: {0}" 3500 3564 msgstr "" ··· 3543 3607 msgid "Failed to update notification declaration" 3544 3608 msgstr "" 3545 3609 3546 - #: src/screens/Messages/Settings.tsx:50 3610 + #: src/screens/Messages/Settings.tsx:39 3547 3611 msgid "Failed to update settings" 3548 3612 msgstr "" 3549 3613 ··· 3562 3626 msgid "Failed to verify handle. Please try again." 3563 3627 msgstr "" 3564 3628 3565 - #: src/Navigation.tsx:290 3629 + #: src/Navigation.tsx:291 3566 3630 msgid "Feed" 3567 3631 msgstr "" 3568 3632 ··· 3593 3657 3594 3658 #: src/view/shell/desktop/RightNav.tsx:106 3595 3659 #: src/view/shell/desktop/RightNav.tsx:107 3596 - #: src/view/shell/Drawer.tsx:357 3660 + #: src/view/shell/Drawer.tsx:368 3597 3661 msgid "Feedback" 3598 3662 msgstr "" 3599 3663 ··· 3603 3667 msgid "Feedback sent to feed operator" 3604 3668 msgstr "" 3605 3669 3606 - #: src/Navigation.tsx:573 3670 + #: src/Navigation.tsx:574 3607 3671 #: src/screens/Search/SearchResults.tsx:73 3608 3672 #: src/screens/StarterPack/StarterPackScreen.tsx:190 3609 3673 #: src/view/screens/Feeds.tsx:511 3610 3674 #: src/view/screens/Profile.tsx:230 3611 3675 #: src/view/screens/SavedFeeds.tsx:104 3612 - #: src/view/shell/desktop/LeftNav.tsx:726 3613 - #: src/view/shell/Drawer.tsx:519 3676 + #: src/view/shell/desktop/LeftNav.tsx:727 3677 + #: src/view/shell/Drawer.tsx:530 3614 3678 msgid "Feeds" 3615 3679 msgstr "" 3616 3680 ··· 3659 3723 #: src/screens/Onboarding/StepFinished.tsx:479 3660 3724 #: src/screens/Onboarding/StepFinished.tsx:591 3661 3725 msgid "Finalizing" 3726 + msgstr "" 3727 + 3728 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:154 3729 + msgid "Finally! Keep track of posts that matter to you. Save them to revisit anytime." 3662 3730 msgstr "" 3663 3731 3664 3732 #: src/view/com/posts/CustomFeedEmptyState.tsx:48 ··· 3788 3856 msgid "Followed by <0>{0}</0>, <1>{1}</1>, and {2, plural, one {# other} other {# others}}" 3789 3857 msgstr "" 3790 3858 3791 - #: src/Navigation.tsx:244 3859 + #: src/Navigation.tsx:245 3792 3860 msgid "Followers of @{0} that you know" 3793 3861 msgstr "" 3794 3862 ··· 3827 3895 msgid "Following feed preferences" 3828 3896 msgstr "" 3829 3897 3830 - #: src/Navigation.tsx:361 3898 + #: src/Navigation.tsx:362 3831 3899 #: src/screens/Settings/FollowingFeedPreferences.tsx:56 3832 3900 msgid "Following Feed Preferences" 3833 3901 msgstr "" ··· 3853 3921 msgid "Food" 3854 3922 msgstr "" 3855 3923 3856 - #: src/components/BlockedGeoOverlay.tsx:45 3924 + #: src/components/BlockedGeoOverlay.tsx:56 3857 3925 msgid "For now, we have made the difficult decision to block access to Bluesky in the state of Mississippi." 3858 3926 msgstr "" 3859 3927 ··· 3921 3989 msgid "Generate a starter pack" 3922 3990 msgstr "" 3923 3991 3924 - #: src/view/shell/Drawer.tsx:361 3992 + #: src/view/shell/Drawer.tsx:372 3925 3993 msgid "Get help" 3926 3994 msgstr "" 3927 3995 ··· 4039 4107 msgid "Go back to previous step" 4040 4108 msgstr "" 4041 4109 4110 + #: src/screens/Bookmarks/components/EmptyState.tsx:43 4111 + #: src/screens/Bookmarks/components/EmptyState.tsx:51 4112 + msgctxt "Button to go back to the home timeline" 4113 + msgid "Go home" 4114 + msgstr "" 4115 + 4042 4116 #: src/view/screens/NotFound.tsx:57 4043 4117 msgid "Go home" 4044 4118 msgstr "" ··· 4068 4142 msgstr "" 4069 4143 4070 4144 #: src/components/ageAssurance/AgeAssuranceAdmonition.tsx:89 4071 - #: src/components/ageAssurance/AgeRestrictedScreen.tsx:75 4072 - #: src/components/ageAssurance/AgeRestrictedScreen.tsx:84 4145 + #: src/components/ageAssurance/AgeRestrictedScreen.tsx:77 4146 + #: src/components/ageAssurance/AgeRestrictedScreen.tsx:86 4073 4147 #: src/screens/Moderation/index.tsx:214 4074 4148 msgid "Go to account settings" 4075 4149 msgstr "" ··· 4083 4157 msgstr "" 4084 4158 4085 4159 #: src/components/dms/ConvoMenu.tsx:227 4086 - #: src/view/shell/desktop/LeftNav.tsx:316 4087 - #: src/view/shell/desktop/LeftNav.tsx:322 4160 + #: src/view/shell/desktop/LeftNav.tsx:317 4161 + #: src/view/shell/desktop/LeftNav.tsx:323 4088 4162 msgid "Go to profile" 4089 4163 msgstr "" 4090 4164 ··· 4131 4205 msgid "Harassment, trolling, or intolerance" 4132 4206 msgstr "" 4133 4207 4134 - #: src/Navigation.tsx:538 4208 + #: src/Navigation.tsx:539 4135 4209 msgid "Hashtag" 4136 4210 msgstr "" 4137 4211 ··· 4152 4226 #: src/screens/Settings/Settings.tsx:240 4153 4227 #: src/view/shell/desktop/RightNav.tsx:124 4154 4228 #: src/view/shell/desktop/RightNav.tsx:125 4155 - #: src/view/shell/Drawer.tsx:370 4229 + #: src/view/shell/Drawer.tsx:381 4156 4230 msgid "Help" 4157 4231 msgstr "" 4158 4232 ··· 4293 4367 msgid "Hold up! We’re gradually giving access to video, and you’re still waiting in line. Check back soon!" 4294 4368 msgstr "" 4295 4369 4296 - #: src/Navigation.tsx:745 4297 - #: src/Navigation.tsx:765 4370 + #: src/Navigation.tsx:754 4371 + #: src/Navigation.tsx:774 4298 4372 #: src/view/shell/bottom-bar/BottomBar.tsx:178 4299 - #: src/view/shell/desktop/LeftNav.tsx:670 4300 - #: src/view/shell/Drawer.tsx:429 4373 + #: src/view/shell/desktop/LeftNav.tsx:671 4374 + #: src/view/shell/Drawer.tsx:440 4301 4375 msgid "Home" 4302 4376 msgstr "" 4303 4377 ··· 4488 4562 msgid "Introducing activity notifications" 4489 4563 msgstr "" 4490 4564 4565 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:37 4566 + msgid "Introducing saved posts AKA bookmarks" 4567 + msgstr "" 4568 + 4491 4569 #: src/screens/Login/LoginForm.tsx:156 4492 4570 #: src/screens/Settings/components/DisableEmail2FADialog.tsx:70 4493 4571 msgid "Invalid 2FA confirmation code." ··· 4535 4613 4536 4614 #: src/screens/StarterPack/Wizard/StepDetails.tsx:31 4537 4615 msgid "Invites, but personal" 4616 + msgstr "" 4617 + 4618 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:86 4619 + msgid "Is your location not accurate? <0>Tap here to confirm your location.</0>" 4538 4620 msgstr "" 4539 4621 4540 4622 #: src/screens/Signup/StepInfo/index.tsx:293 ··· 4621 4703 msgid "Labels on your content" 4622 4704 msgstr "" 4623 4705 4624 - #: src/Navigation.tsx:217 4706 + #: src/Navigation.tsx:218 4625 4707 msgid "Language Settings" 4626 4708 msgstr "" 4627 4709 ··· 4635 4717 msgid "Larger" 4636 4718 msgstr "" 4637 4719 4638 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:139 4720 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:187 4639 4721 msgid "Last initiated {timeAgo} ago" 4640 4722 msgstr "" 4641 4723 4642 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:137 4724 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:185 4643 4725 msgid "Last initiated just now" 4644 4726 msgstr "" 4645 4727 ··· 4773 4855 msgstr "" 4774 4856 4775 4857 #. Accessibility label for the like button when the post has not been liked, verb form followed by number of likes and noun form 4776 - #: src/components/PostControls/index.tsx:253 4858 + #: src/components/PostControls/index.tsx:269 4777 4859 msgid "Like ({0, plural, one {# like} other {# likes}})" 4778 4860 msgstr "" 4779 4861 ··· 4786 4868 msgid "Like 10 posts to train the Discover feed" 4787 4869 msgstr "" 4788 4870 4789 - #: src/Navigation.tsx:451 4871 + #: src/Navigation.tsx:452 4790 4872 msgid "Like notifications" 4791 4873 msgstr "" 4792 4874 ··· 4798 4880 msgid "Like this labeler" 4799 4881 msgstr "" 4800 4882 4801 - #: src/Navigation.tsx:295 4802 - #: src/Navigation.tsx:300 4883 + #: src/Navigation.tsx:296 4884 + #: src/Navigation.tsx:301 4803 4885 msgid "Liked by" 4804 4886 msgstr "" 4805 4887 ··· 4834 4916 msgid "Likes of your reposts" 4835 4917 msgstr "" 4836 4918 4837 - #: src/Navigation.tsx:475 4919 + #: src/Navigation.tsx:476 4838 4920 msgid "Likes of your reposts notifications" 4839 4921 msgstr "" 4840 4922 4841 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:466 4923 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:472 4842 4924 msgid "Likes on this post" 4843 4925 msgstr "" 4844 4926 ··· 4847 4929 msgid "Linear" 4848 4930 msgstr "" 4849 4931 4850 - #: src/Navigation.tsx:250 4932 + #: src/Navigation.tsx:251 4851 4933 msgid "List" 4852 4934 msgstr "" 4853 4935 ··· 4909 4991 msgid "List unmuted" 4910 4992 msgstr "" 4911 4993 4912 - #: src/Navigation.tsx:171 4994 + #: src/Navigation.tsx:172 4913 4995 #: src/view/screens/Lists.tsx:65 4914 4996 #: src/view/screens/Profile.tsx:224 4915 4997 #: src/view/screens/Profile.tsx:232 4916 - #: src/view/shell/desktop/LeftNav.tsx:744 4917 - #: src/view/shell/Drawer.tsx:534 4998 + #: src/view/shell/desktop/LeftNav.tsx:745 4999 + #: src/view/shell/Drawer.tsx:545 4918 5000 msgid "Lists" 4919 5001 msgstr "" 4920 5002 ··· 4962 5044 msgid "Loading..." 4963 5045 msgstr "" 4964 5046 4965 - #: src/Navigation.tsx:320 5047 + #: src/Navigation.tsx:321 4966 5048 msgid "Log" 4967 5049 msgstr "" 4968 5050 ··· 4975 5057 msgstr "" 4976 5058 4977 5059 #: src/view/shell/desktop/RightNav.tsx:131 4978 - #: src/view/shell/Drawer.tsx:672 5060 + #: src/view/shell/Drawer.tsx:709 4979 5061 msgid "Logo by <0>@sawaratsuki.bsky.social</0>" 4980 5062 msgstr "" 4981 5063 ··· 5053 5135 msgid "Media that may be disturbing or inappropriate for some audiences." 5054 5136 msgstr "" 5055 5137 5056 - #: src/Navigation.tsx:435 5138 + #: src/Navigation.tsx:436 5057 5139 msgid "Mention notifications" 5058 5140 msgstr "" 5059 5141 ··· 5110 5192 msgid "Message options" 5111 5193 msgstr "" 5112 5194 5113 - #: src/Navigation.tsx:760 5195 + #: src/Navigation.tsx:769 5114 5196 msgid "Messages" 5115 5197 msgstr "" 5116 5198 ··· 5119 5201 msgid "Midnight" 5120 5202 msgstr "" 5121 5203 5122 - #: src/Navigation.tsx:499 5204 + #: src/Navigation.tsx:500 5123 5205 msgid "Miscellaneous notifications" 5124 5206 msgstr "" 5125 5207 ··· 5133 5215 msgid "Misleading Post" 5134 5216 msgstr "" 5135 5217 5136 - #: src/Navigation.tsx:176 5218 + #: src/Navigation.tsx:177 5137 5219 #: src/screens/Moderation/index.tsx:100 5138 5220 #: src/screens/Settings/Settings.tsx:188 5139 5221 #: src/screens/Settings/Settings.tsx:191 ··· 5172 5254 msgid "Moderation lists" 5173 5255 msgstr "" 5174 5256 5175 - #: src/Navigation.tsx:181 5257 + #: src/Navigation.tsx:182 5176 5258 #: src/view/screens/ModerationModlists.tsx:65 5177 5259 msgid "Moderation Lists" 5178 5260 msgstr "" ··· 5181 5263 msgid "moderation settings" 5182 5264 msgstr "" 5183 5265 5184 - #: src/Navigation.tsx:310 5266 + #: src/Navigation.tsx:311 5185 5267 msgid "Moderation states" 5186 5268 msgstr "" 5187 5269 ··· 5290 5372 msgid "Muted accounts" 5291 5373 msgstr "" 5292 5374 5293 - #: src/Navigation.tsx:186 5375 + #: src/Navigation.tsx:187 5294 5376 #: src/view/screens/ModerationMutedAccounts.tsx:118 5295 5377 msgid "Muted Accounts" 5296 5378 msgstr "" ··· 5358 5440 msgid "Navigates to the next screen" 5359 5441 msgstr "" 5360 5442 5361 - #: src/view/shell/Drawer.tsx:77 5443 + #: src/view/shell/Drawer.tsx:78 5362 5444 msgid "Navigates to your profile" 5363 5445 msgstr "" 5364 5446 ··· 5398 5480 msgstr "" 5399 5481 5400 5482 #: src/components/dms/dialogs/NewChatDialog.tsx:67 5401 - #: src/screens/Messages/ChatList.tsx:390 5402 - #: src/screens/Messages/ChatList.tsx:397 5483 + #: src/screens/Messages/ChatList.tsx:401 5484 + #: src/screens/Messages/ChatList.tsx:408 5403 5485 msgid "New chat" 5404 5486 msgstr "" 5405 5487 ··· 5408 5490 msgstr "" 5409 5491 5410 5492 #: src/components/dialogs/nuxs/ActivitySubscriptions.tsx:74 5493 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:73 5411 5494 #: src/components/dialogs/nuxs/InitialVerificationAnnouncement.tsx:63 5412 5495 msgid "New Feature" 5413 5496 msgstr "" 5414 5497 5415 - #: src/Navigation.tsx:467 5498 + #: src/Navigation.tsx:468 5416 5499 msgid "New follower notifications" 5417 5500 msgstr "" 5418 5501 ··· 5461 5544 msgid "New post" 5462 5545 msgstr "" 5463 5546 5464 - #: src/view/shell/desktop/LeftNav.tsx:579 5547 + #: src/view/shell/desktop/LeftNav.tsx:580 5465 5548 msgctxt "action" 5466 5549 msgid "New Post" 5467 5550 msgstr "" ··· 5543 5626 msgid "No expiry set" 5544 5627 msgstr "" 5545 5628 5546 - #: src/components/dialogs/GifSelect.tsx:230 5629 + #: src/components/dialogs/GifSelect.tsx:219 5547 5630 msgid "No featured GIFs found. There may be an issue with Tenor." 5548 5631 msgstr "" 5549 5632 ··· 5577 5660 msgid "No notifications yet!" 5578 5661 msgstr "" 5579 5662 5580 - #: src/screens/Messages/Settings.tsx:117 5581 - #: src/screens/Messages/Settings.tsx:120 5663 + #: src/screens/Messages/Settings.tsx:106 5664 + #: src/screens/Messages/Settings.tsx:109 5582 5665 #: src/screens/Settings/ActivityPrivacySettings.tsx:129 5583 5666 #: src/screens/Settings/ActivityPrivacySettings.tsx:134 5584 5667 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:160 ··· 5637 5720 msgid "No results." 5638 5721 msgstr "" 5639 5722 5640 - #: src/components/dialogs/GifSelect.tsx:228 5723 + #: src/components/dialogs/GifSelect.tsx:217 5641 5724 msgid "No search results found for \"{search}\"." 5642 5725 msgstr "" 5643 5726 ··· 5679 5762 msgid "Not followed by anyone you're following" 5680 5763 msgstr "" 5681 5764 5682 - #: src/Navigation.tsx:166 5765 + #: src/Navigation.tsx:167 5683 5766 #: src/view/screens/Profile.tsx:125 5684 5767 msgid "Not Found" 5768 + msgstr "" 5769 + 5770 + #: src/components/BlockedGeoOverlay.tsx:126 5771 + msgid "Not in Mississippi?" 5685 5772 msgstr "" 5686 5773 5687 5774 #: src/view/com/profile/ProfileMenu.tsx:497 ··· 5696 5783 msgid "Note: This post is only visible to logged-in users." 5697 5784 msgstr "" 5698 5785 5699 - #: src/screens/Messages/ChatList.tsx:291 5786 + #: src/screens/Messages/ChatList.tsx:302 5700 5787 msgid "Nothing here" 5701 5788 msgstr "" 5702 5789 5703 - #: src/Navigation.tsx:421 5704 - #: src/Navigation.tsx:568 5790 + #: src/screens/Bookmarks/components/EmptyState.tsx:35 5791 + msgid "Nothing saved yet" 5792 + msgstr "" 5793 + 5794 + #: src/Navigation.tsx:422 5795 + #: src/Navigation.tsx:569 5705 5796 #: src/view/screens/Notifications.tsx:136 5706 5797 msgid "Notification settings" 5707 5798 msgstr "" 5708 5799 5709 - #: src/screens/Messages/Settings.tsx:139 5800 + #: src/screens/Messages/Settings.tsx:128 5710 5801 msgid "Notification sounds" 5711 5802 msgstr "" 5712 5803 5713 - #: src/screens/Messages/Settings.tsx:136 5804 + #: src/screens/Messages/Settings.tsx:125 5714 5805 msgid "Notification Sounds" 5715 5806 msgstr "" 5716 5807 5717 - #: src/Navigation.tsx:563 5718 - #: src/Navigation.tsx:755 5808 + #: src/Navigation.tsx:564 5809 + #: src/Navigation.tsx:764 5719 5810 #: src/screens/Notifications/ActivityList.tsx:29 5720 5811 #: src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx:90 5721 5812 #: src/screens/Settings/NotificationSettings/index.tsx:92 ··· 5732 5823 #: src/screens/Settings/Settings.tsx:199 5733 5824 #: src/view/screens/Notifications.tsx:130 5734 5825 #: src/view/shell/bottom-bar/BottomBar.tsx:252 5735 - #: src/view/shell/desktop/LeftNav.tsx:707 5736 - #: src/view/shell/Drawer.tsx:482 5826 + #: src/view/shell/desktop/LeftNav.tsx:708 5827 + #: src/view/shell/Drawer.tsx:493 5737 5828 msgid "Notifications" 5738 5829 msgstr "" 5739 5830 ··· 5764 5855 msgid "Off" 5765 5856 msgstr "" 5766 5857 5767 - #: src/components/dialogs/GifSelect.tsx:267 5858 + #: src/components/dialogs/GifSelect.tsx:256 5768 5859 #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:365 5769 5860 #: src/view/com/util/ErrorBoundary.tsx:57 5770 5861 msgid "Oh no!" ··· 5783 5874 msgstr "" 5784 5875 5785 5876 #: src/screens/Login/PasswordUpdatedForm.tsx:37 5786 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:673 5877 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:695 5787 5878 msgid "Okay" 5788 5879 msgstr "" 5789 5880 ··· 5912 6003 msgid "Open pack" 5913 6004 msgstr "" 5914 6005 5915 - #: src/components/PostControls/PostMenu/index.tsx:62 6006 + #: src/components/PostControls/PostMenu/index.tsx:65 5916 6007 msgid "Open post options menu" 5917 6008 msgstr "" 5918 6009 ··· 5921 6012 msgid "Open profile" 5922 6013 msgstr "" 5923 6014 5924 - #: src/components/PostControls/ShareMenu/index.tsx:87 6015 + #: src/components/PostControls/ShareMenu/index.tsx:90 5925 6016 msgid "Open share menu" 5926 6017 msgstr "" 5927 6018 ··· 6131 6222 msgid "People" 6132 6223 msgstr "" 6133 6224 6134 - #: src/Navigation.tsx:237 6225 + #: src/Navigation.tsx:238 6135 6226 msgid "People followed by @{0}" 6136 6227 msgstr "" 6137 6228 6138 - #: src/Navigation.tsx:230 6229 + #: src/Navigation.tsx:231 6139 6230 msgid "People following @{0}" 6140 6231 msgstr "" 6141 6232 ··· 6408 6499 msgid "Post blocked" 6409 6500 msgstr "" 6410 6501 6411 - #: src/Navigation.tsx:263 6412 - #: src/Navigation.tsx:270 6413 - #: src/Navigation.tsx:277 6414 - #: src/Navigation.tsx:284 6502 + #: src/Navigation.tsx:264 6503 + #: src/Navigation.tsx:271 6504 + #: src/Navigation.tsx:278 6505 + #: src/Navigation.tsx:285 6415 6506 msgid "Post by @{0}" 6416 6507 msgstr "" 6417 6508 ··· 6445 6536 msgid "Post interaction settings" 6446 6537 msgstr "" 6447 6538 6448 - #: src/Navigation.tsx:197 6539 + #: src/Navigation.tsx:198 6449 6540 #: src/screens/ModerationInteractionSettings/index.tsx:34 6450 6541 msgid "Post Interaction Settings" 6451 6542 msgstr "" ··· 6463 6554 #: src/state/queries/pinned-post.ts:59 6464 6555 msgctxt "toast" 6465 6556 msgid "Post pinned" 6557 + msgstr "" 6558 + 6559 + #: src/components/PostControls/BookmarkButton.tsx:57 6560 + msgid "Post saved" 6466 6561 msgstr "" 6467 6562 6468 6563 #: src/state/queries/pinned-post.ts:61 ··· 6537 6632 msgid "Privacy and security" 6538 6633 msgstr "" 6539 6634 6540 - #: src/Navigation.tsx:406 6541 - #: src/Navigation.tsx:414 6635 + #: src/Navigation.tsx:407 6636 + #: src/Navigation.tsx:415 6542 6637 #: src/screens/Settings/ActivityPrivacySettings.tsx:40 6543 6638 #: src/screens/Settings/PrivacyAndSecuritySettings.tsx:45 6544 6639 msgid "Privacy and Security" ··· 6550 6645 6551 6646 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:35 6552 6647 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:102 6553 - #: src/Navigation.tsx:330 6648 + #: src/Navigation.tsx:331 6554 6649 #: src/screens/Settings/AboutSettings.tsx:92 6555 6650 #: src/screens/Settings/AboutSettings.tsx:95 6556 6651 #: src/view/screens/PrivacyPolicy.tsx:31 6557 - #: src/view/shell/Drawer.tsx:667 6558 - #: src/view/shell/Drawer.tsx:668 6652 + #: src/view/shell/Drawer.tsx:704 6653 + #: src/view/shell/Drawer.tsx:705 6559 6654 msgid "Privacy Policy" 6560 6655 msgstr "" 6561 6656 ··· 6574 6669 msgstr "" 6575 6670 6576 6671 #: src/view/shell/bottom-bar/BottomBar.tsx:316 6577 - #: src/view/shell/desktop/LeftNav.tsx:762 6578 - #: src/view/shell/Drawer.tsx:76 6579 - #: src/view/shell/Drawer.tsx:559 6672 + #: src/view/shell/desktop/LeftNav.tsx:781 6673 + #: src/view/shell/Drawer.tsx:77 6674 + #: src/view/shell/Drawer.tsx:596 6580 6675 msgid "Profile" 6581 6676 msgstr "" 6582 6677 ··· 6645 6740 msgid "QR code saved to your camera roll!" 6646 6741 msgstr "" 6647 6742 6648 - #: src/Navigation.tsx:443 6743 + #: src/Navigation.tsx:444 6649 6744 msgid "Quote notifications" 6650 6745 msgstr "" 6651 6746 6652 - #: src/components/PostControls/RepostButton.tsx:174 6653 - #: src/components/PostControls/RepostButton.tsx:197 6747 + #: src/components/PostControls/RepostButton.tsx:175 6748 + #: src/components/PostControls/RepostButton.tsx:198 6654 6749 #: src/components/PostControls/RepostButton.web.tsx:84 6655 6750 #: src/components/PostControls/RepostButton.web.tsx:91 6656 6751 msgid "Quote post" ··· 6664 6759 msgid "Quote post was successfully detached" 6665 6760 msgstr "" 6666 6761 6667 - #: src/components/PostControls/RepostButton.tsx:173 6668 - #: src/components/PostControls/RepostButton.tsx:195 6762 + #: src/components/PostControls/RepostButton.tsx:174 6763 + #: src/components/PostControls/RepostButton.tsx:196 6669 6764 #: src/components/PostControls/RepostButton.web.tsx:83 6670 6765 #: src/components/PostControls/RepostButton.web.tsx:90 6671 6766 msgid "Quote posts disabled" ··· 6682 6777 msgid "Quotes" 6683 6778 msgstr "" 6684 6779 6685 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:450 6780 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:456 6686 6781 msgid "Quotes of this post" 6687 6782 msgstr "" 6688 6783 ··· 6724 6819 msgid "Read more replies" 6725 6820 msgstr "" 6726 6821 6727 - #: src/components/BlockedGeoOverlay.tsx:29 6822 + #: src/components/BlockedGeoOverlay.tsx:40 6728 6823 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:112 6729 6824 msgid "Read our blog post" 6730 6825 msgstr "" ··· 6743 6838 msgid "Read the Bluesky Terms of Service" 6744 6839 msgstr "" 6745 6840 6841 + #: src/components/WelcomeModal.tsx:146 6842 + msgid "Real conversations." 6843 + msgstr "" 6844 + 6845 + #: src/components/WelcomeModal.tsx:144 6846 + msgid "Real people." 6847 + msgstr "" 6848 + 6746 6849 #: src/screens/Takendown.tsx:162 6747 6850 #: src/screens/Takendown.tsx:170 6748 6851 msgid "Reason for appeal" ··· 6785 6888 msgid "Reject chat request" 6786 6889 msgstr "" 6787 6890 6788 - #: src/screens/Messages/ChatList.tsx:274 6891 + #: src/screens/Messages/ChatList.tsx:285 6789 6892 #: src/screens/Messages/Inbox.tsx:212 6790 6893 msgid "Reload conversations" 6791 6894 msgstr "" ··· 6797 6900 #: src/components/FeedCard.tsx:343 6798 6901 #: src/components/StarterPack/Wizard/WizardListCard.tsx:104 6799 6902 #: src/components/StarterPack/Wizard/WizardListCard.tsx:111 6903 + #: src/screens/Bookmarks/index.tsx:255 6800 6904 #: src/screens/Settings/Settings.tsx:662 6801 6905 #: src/view/com/modals/UserAddRemoveLists.tsx:235 6802 6906 #: src/view/com/posts/PostFeedErrorMessage.tsx:217 ··· 6860 6964 msgid "Remove from saved feeds" 6861 6965 msgstr "" 6862 6966 6967 + #: src/components/PostControls/BookmarkButton.tsx:125 6968 + #: src/screens/Bookmarks/index.tsx:249 6969 + msgid "Remove from saved posts" 6970 + msgstr "" 6971 + 6863 6972 #: src/components/FeedCard.tsx:338 6864 6973 msgid "Remove from your feeds?" 6865 6974 msgstr "" ··· 6881 6990 msgid "Remove profile" 6882 6991 msgstr "" 6883 6992 6884 - #: src/components/PostControls/RepostButton.tsx:151 6885 - #: src/components/PostControls/RepostButton.tsx:161 6993 + #: src/components/PostControls/RepostButton.tsx:152 6994 + #: src/components/PostControls/RepostButton.tsx:162 6886 6995 msgid "Remove repost" 6887 6996 msgstr "" 6888 6997 ··· 6926 7035 msgid "Removed from saved feeds" 6927 7036 msgstr "" 6928 7037 7038 + #: src/components/PostControls/BookmarkButton.tsx:92 7039 + #: src/screens/Bookmarks/index.tsx:207 7040 + msgid "Removed from saved posts" 7041 + msgstr "" 7042 + 6929 7043 #: src/components/dialogs/StarterPackDialog.tsx:277 6930 7044 msgid "Removed from starter pack" 6931 7045 msgstr "" ··· 6989 7103 msgstr "" 6990 7104 6991 7105 #. Accessibility label for the reply button, verb form followed by number of replies and noun form 6992 - #: src/components/PostControls/index.tsx:207 7106 + #: src/components/PostControls/index.tsx:223 6993 7107 msgid "Reply ({0, plural, one {# reply} other {# replies}})" 6994 7108 msgstr "" 6995 7109 ··· 7003 7117 msgid "Reply Hidden by You" 7004 7118 msgstr "" 7005 7119 7006 - #: src/Navigation.tsx:427 7120 + #: src/Navigation.tsx:428 7007 7121 msgid "Reply notifications" 7008 7122 msgstr "" 7009 7123 ··· 7116 7230 msgid "Report this user" 7117 7231 msgstr "" 7118 7232 7119 - #: src/components/PostControls/RepostButton.tsx:152 7120 - #: src/components/PostControls/RepostButton.tsx:163 7233 + #: src/components/PostControls/RepostButton.tsx:153 7234 + #: src/components/PostControls/RepostButton.tsx:164 7121 7235 #: src/components/PostControls/RepostButton.web.tsx:68 7122 7236 #: src/components/PostControls/RepostButton.web.tsx:75 7123 7237 msgctxt "action" ··· 7125 7239 msgstr "" 7126 7240 7127 7241 #. Accessibility label for the repost button when the post has not been reposted, verb form followed by number of reposts and noun form 7128 - #: src/components/PostControls/RepostButton.tsx:76 7242 + #: src/components/PostControls/RepostButton.tsx:77 7129 7243 msgid "Repost ({0, plural, one {# repost} other {# reposts}})" 7130 7244 msgstr "" 7131 7245 7132 - #: src/Navigation.tsx:459 7246 + #: src/Navigation.tsx:460 7133 7247 msgid "Repost notifications" 7134 7248 msgstr "" 7135 7249 7136 - #: src/components/PostControls/RepostButton.tsx:144 7250 + #: src/components/PostControls/RepostButton.tsx:145 7137 7251 #: src/components/PostControls/RepostButton.web.tsx:43 7138 7252 #: src/components/PostControls/RepostButton.web.tsx:103 7139 7253 #: src/screens/StarterPack/StarterPackScreen.tsx:561 ··· 7163 7277 msgid "Reposts" 7164 7278 msgstr "" 7165 7279 7166 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:432 7280 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:438 7167 7281 msgid "Reposts of this post" 7168 7282 msgstr "" 7169 7283 ··· 7173 7287 msgid "Reposts of your reposts" 7174 7288 msgstr "" 7175 7289 7176 - #: src/Navigation.tsx:483 7290 + #: src/Navigation.tsx:484 7177 7291 msgid "Reposts of your reposts notifications" 7178 7292 msgstr "" 7179 7293 ··· 7252 7366 #: src/components/StarterPack/ProfileStarterPacks.tsx:346 7253 7367 #: src/screens/Login/LoginForm.tsx:323 7254 7368 #: src/screens/Login/LoginForm.tsx:330 7255 - #: src/screens/Messages/ChatList.tsx:280 7369 + #: src/screens/Messages/ChatList.tsx:291 7256 7370 #: src/screens/Messages/components/MessageListError.tsx:25 7257 7371 #: src/screens/Messages/Inbox.tsx:218 7258 7372 #: src/screens/Onboarding/StepInterests/index.tsx:226 ··· 7352 7466 msgid "Save to my feeds" 7353 7467 msgstr "" 7354 7468 7469 + #: src/view/shell/desktop/LeftNav.tsx:763 7470 + #: src/view/shell/Drawer.tsx:571 7471 + msgid "Saved" 7472 + msgstr "" 7473 + 7355 7474 #: src/view/screens/SavedFeeds.tsx:172 7356 7475 msgid "Saved Feeds" 7357 7476 msgstr "" 7358 7477 7478 + #: src/components/dialogs/nuxs/BookmarksAnnouncement.tsx:143 7479 + #: src/Navigation.tsx:608 7480 + #: src/screens/Bookmarks/index.tsx:55 7481 + msgid "Saved Posts" 7482 + msgstr "" 7483 + 7359 7484 #: src/screens/Profile/components/ProfileFeedHeader.tsx:132 7360 7485 #: src/view/screens/ProfileList.tsx:372 7361 7486 msgid "Saved to your feeds" ··· 7365 7490 msgid "Saves image crop settings" 7366 7491 msgstr "" 7367 7492 7493 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:484 7494 + msgid "Saves of this post" 7495 + msgstr "" 7496 + 7368 7497 #: src/components/dms/ChatEmptyPill.tsx:33 7369 7498 #: src/components/NewskieDialog.tsx:105 7370 7499 #: src/view/com/notifications/NotificationFeedItem.tsx:751 ··· 7398 7527 msgid "Search" 7399 7528 msgstr "" 7400 7529 7401 - #: src/Navigation.tsx:256 7530 + #: src/Navigation.tsx:257 7402 7531 #: src/screens/Profile/ProfileSearch.tsx:37 7403 7532 msgid "Search @{0}'s posts" 7404 7533 msgstr "" ··· 7445 7574 msgid "Search for posts, users, or feeds" 7446 7575 msgstr "" 7447 7576 7448 - #: src/components/dialogs/GifSelect.tsx:178 7577 + #: src/components/dialogs/GifSelect.tsx:167 7449 7578 msgid "Search GIFs" 7450 7579 msgstr "" 7451 7580 ··· 7472 7601 msgid "Search profiles" 7473 7602 msgstr "" 7474 7603 7475 - #: src/components/dialogs/GifSelect.tsx:179 7604 + #: src/components/dialogs/GifSelect.tsx:168 7476 7605 msgid "Search Tenor" 7477 7606 msgstr "" 7478 7607 ··· 7580 7709 msgid "Select GIF" 7581 7710 msgstr "" 7582 7711 7583 - #: src/components/dialogs/GifSelect.tsx:305 7712 + #: src/components/dialogs/GifSelect.tsx:294 7584 7713 msgid "Select GIF \"{0}\"" 7585 7714 msgstr "" 7586 7715 ··· 7680 7809 msgid "Send Email" 7681 7810 msgstr "" 7682 7811 7683 - #: src/view/shell/Drawer.tsx:350 7812 + #: src/view/shell/Drawer.tsx:361 7684 7813 msgid "Send feedback" 7685 7814 msgstr "" 7686 7815 ··· 7753 7882 msgid "Sets email for password reset" 7754 7883 msgstr "" 7755 7884 7756 - #: src/Navigation.tsx:212 7885 + #: src/Navigation.tsx:213 7757 7886 #: src/screens/Settings/Settings.tsx:99 7758 - #: src/view/shell/desktop/LeftNav.tsx:780 7759 - #: src/view/shell/Drawer.tsx:572 7887 + #: src/view/shell/desktop/LeftNav.tsx:799 7888 + #: src/view/shell/Drawer.tsx:609 7760 7889 msgid "Settings" 7761 7890 msgstr "" 7762 7891 ··· 7893 8022 msgid "Share your favorite feed!" 7894 8023 msgstr "" 7895 8024 7896 - #: src/Navigation.tsx:315 8025 + #: src/Navigation.tsx:316 7897 8026 msgid "Shared Preferences Tester" 7898 8027 msgstr "" 7899 8028 ··· 7995 8124 msgid "Show warning and filter from feeds" 7996 8125 msgstr "" 7997 8126 7998 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:614 8127 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:636 7999 8128 msgid "Shows information about when this post was created" 8000 8129 msgstr "" 8001 8130 ··· 8010 8139 8011 8140 #: src/components/dialogs/Signin.tsx:97 8012 8141 #: src/components/dialogs/Signin.tsx:99 8142 + #: src/components/WelcomeModal.tsx:194 8143 + #: src/components/WelcomeModal.tsx:206 8013 8144 #: src/screens/Login/index.tsx:122 8014 8145 #: src/screens/Login/index.tsx:143 8015 8146 #: src/screens/Login/LoginForm.tsx:181 ··· 8063 8194 #: src/screens/SignupQueued.tsx:93 8064 8195 #: src/screens/SignupQueued.tsx:96 8065 8196 #: src/screens/Takendown.tsx:85 8066 - #: src/view/shell/desktop/LeftNav.tsx:211 8067 - #: src/view/shell/desktop/LeftNav.tsx:268 8068 - #: src/view/shell/desktop/LeftNav.tsx:271 8197 + #: src/view/shell/desktop/LeftNav.tsx:212 8198 + #: src/view/shell/desktop/LeftNav.tsx:269 8199 + #: src/view/shell/desktop/LeftNav.tsx:272 8069 8200 msgid "Sign out" 8070 8201 msgstr "" 8071 8202 ··· 8074 8205 msgstr "" 8075 8206 8076 8207 #: src/screens/Settings/Settings.tsx:285 8077 - #: src/view/shell/desktop/LeftNav.tsx:208 8208 + #: src/view/shell/desktop/LeftNav.tsx:209 8078 8209 msgid "Sign out?" 8079 8210 msgstr "" 8080 8211 ··· 8116 8247 8117 8248 #: src/components/dialogs/EmailDialog/screens/VerificationReminder.tsx:91 8118 8249 msgid "Snoozes the reminder" 8250 + msgstr "" 8251 + 8252 + #: src/components/WelcomeModal.tsx:148 8253 + msgid "Social media you control." 8119 8254 msgstr "" 8120 8255 8121 8256 #: src/screens/Onboarding/index.tsx:53 ··· 8242 8377 msgid "Start chat with {displayName}" 8243 8378 msgstr "" 8244 8379 8245 - #: src/Navigation.tsx:578 8246 - #: src/Navigation.tsx:583 8380 + #: src/Navigation.tsx:579 8381 + #: src/Navigation.tsx:584 8247 8382 #: src/screens/StarterPack/Wizard/index.tsx:209 8248 8383 msgid "Starter Pack" 8249 8384 msgstr "" ··· 8287 8422 msgid "Storage cleared, you need to restart the app now." 8288 8423 msgstr "" 8289 8424 8290 - #: src/Navigation.tsx:305 8425 + #: src/Navigation.tsx:306 8291 8426 #: src/screens/Settings/Settings.tsx:448 8292 8427 msgid "Storybook" 8293 8428 msgstr "" ··· 8377 8512 msgid "Sunset" 8378 8513 msgstr "" 8379 8514 8380 - #: src/Navigation.tsx:325 8515 + #: src/Navigation.tsx:326 8381 8516 #: src/view/screens/Support.tsx:31 8382 8517 #: src/view/screens/Support.tsx:34 8383 8518 msgid "Support" ··· 8386 8521 #: src/screens/Settings/Settings.tsx:123 8387 8522 #: src/screens/Settings/Settings.tsx:137 8388 8523 #: src/screens/Settings/Settings.tsx:604 8389 - #: src/view/shell/desktop/LeftNav.tsx:246 8524 + #: src/view/shell/desktop/LeftNav.tsx:247 8390 8525 msgid "Switch account" 8391 8526 msgstr "" 8392 8527 ··· 8395 8530 msgid "Switch Account" 8396 8531 msgstr "" 8397 8532 8398 - #: src/view/shell/desktop/LeftNav.tsx:109 8533 + #: src/view/shell/desktop/LeftNav.tsx:110 8399 8534 msgid "Switch accounts" 8400 8535 msgstr "" 8401 8536 8402 - #: src/view/shell/desktop/LeftNav.tsx:345 8537 + #: src/view/shell/desktop/LeftNav.tsx:346 8403 8538 msgid "Switch to {0}" 8404 8539 msgstr "" 8405 8540 ··· 8419 8554 8420 8555 #: src/components/dialogs/MutedWords.tsx:282 8421 8556 msgid "Tags only" 8557 + msgstr "" 8558 + 8559 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:116 8560 + msgid "Tap below to allow Bluesky to access your GPS location. We will then use that data to more accurately determine the content and features available in your region." 8422 8561 msgstr "" 8423 8562 8424 8563 #: src/view/com/feeds/MissingFeed.tsx:89 ··· 8480 8619 #: src/components/dialogs/BirthDateSettings.tsx:135 8481 8620 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:30 8482 8621 #: src/components/PolicyUpdateOverlay/updates/202508/index.tsx:97 8483 - #: src/Navigation.tsx:335 8622 + #: src/Navigation.tsx:336 8484 8623 #: src/screens/Settings/AboutSettings.tsx:84 8485 8624 #: src/screens/Settings/AboutSettings.tsx:87 8486 8625 #: src/view/screens/TermsOfService.tsx:31 8487 - #: src/view/shell/Drawer.tsx:660 8488 - #: src/view/shell/Drawer.tsx:662 8626 + #: src/view/shell/Drawer.tsx:697 8627 + #: src/view/shell/Drawer.tsx:699 8489 8628 msgid "Terms of Service" 8490 8629 msgstr "" 8491 8630 ··· 8523 8662 8524 8663 #: src/components/intents/VerifyEmailIntentDialog.tsx:82 8525 8664 msgid "Thanks, you have successfully verified your email address. You can close this dialog." 8665 + msgstr "" 8666 + 8667 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:115 8668 + #: src/components/BlockedGeoOverlay.tsx:169 8669 + msgid "Thanks! You're all set." 8526 8670 msgstr "" 8527 8671 8528 8672 #: src/screens/Settings/components/ChangeHandleDialog.tsx:497 ··· 8663 8807 msgid "There is no time limit for account deactivation, come back any time." 8664 8808 msgstr "" 8665 8809 8666 - #: src/components/dialogs/GifSelect.tsx:225 8810 + #: src/components/dialogs/GifSelect.tsx:214 8667 8811 msgid "There was an issue connecting to Tenor." 8668 8812 msgstr "" 8669 8813 ··· 8748 8892 msgid "There was an issue. Please check your internet connection and try again." 8749 8893 msgstr "" 8750 8894 8751 - #: src/components/dialogs/GifSelect.tsx:269 8895 + #: src/components/dialogs/GifSelect.tsx:258 8752 8896 #: src/view/com/composer/select-language/SelectPostLanguagesDialog.tsx:367 8753 8897 #: src/view/com/util/ErrorBoundary.tsx:59 8754 8898 msgid "There was an unexpected issue in the application. Please let us know if this happened to you!" ··· 8910 9054 msgid "This moderation service is unavailable. See below for more details. If this issue persists, contact us." 8911 9055 msgstr "" 8912 9056 8913 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:654 9057 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:676 8914 9058 msgid "This post claims to have been created on <0>{0}</0>, but was first seen by Bluesky on <1>{1}</1>." 8915 9059 msgstr "" 8916 9060 ··· 8920 9064 8921 9065 #: src/components/PostControls/ShareMenu/ShareMenuItems.tsx:140 8922 9066 msgid "This post is only visible to logged-in users." 9067 + msgstr "" 9068 + 9069 + #: src/screens/Bookmarks/index.tsx:245 9070 + msgid "This post was deleted by its author" 8923 9071 msgstr "" 8924 9072 8925 9073 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:712 ··· 9018 9166 msgid "Threaded" 9019 9167 msgstr "" 9020 9168 9021 - #: src/Navigation.tsx:368 9169 + #: src/Navigation.tsx:369 9022 9170 msgid "Threads Preferences" 9023 9171 msgstr "" 9024 9172 ··· 9068 9216 msgid "Top replies first" 9069 9217 msgstr "" 9070 9218 9071 - #: src/Navigation.tsx:543 9219 + #: src/Navigation.tsx:544 9072 9220 msgid "Topic" 9073 9221 msgstr "" 9074 9222 ··· 9076 9224 #: src/components/dms/MessageContextMenu.tsx:139 9077 9225 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:444 9078 9226 #: src/components/PostControls/PostMenu/PostMenuItems.tsx:446 9079 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:576 9080 - #: src/screens/PostThread/components/ThreadItemAnchor.tsx:579 9227 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:598 9228 + #: src/screens/PostThread/components/ThreadItemAnchor.tsx:601 9081 9229 msgid "Translate" 9082 9230 msgstr "" 9083 9231 ··· 9119 9267 msgid "Type:" 9120 9268 msgstr "" 9121 9269 9270 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:92 9271 + msgid "Unable to access location. You'll need to visit your system settings to enable location services for Bluesky." 9272 + msgstr "" 9273 + 9122 9274 #: src/lib/hooks/useCleanError.ts:27 9123 9275 #: src/lib/strings/errors.ts:11 9124 9276 msgid "Unable to connect. Please check your internet connection and try again." ··· 9149 9301 msgid "Unapply Pull Request {currentChannel}" 9150 9302 msgstr "" 9151 9303 9152 - #: src/components/ageAssurance/AgeRestrictedScreen.tsx:51 9304 + #: src/components/ageAssurance/AgeRestrictedScreen.tsx:53 9153 9305 msgid "Unavailable" 9154 9306 msgstr "" 9155 9307 ··· 9189 9341 msgid "Unblock list" 9190 9342 msgstr "" 9191 9343 9344 + #: src/components/PostControls/BookmarkButton.tsx:38 9345 + msgctxt "Button label to undo saving/removing a post from saved posts." 9346 + msgid "Undo" 9347 + msgstr "" 9348 + 9192 9349 #: src/components/PostControls/RepostButton.web.tsx:67 9193 9350 #: src/components/PostControls/RepostButton.web.tsx:74 9194 9351 msgid "Undo repost" 9195 9352 msgstr "" 9196 9353 9197 9354 #. Accessibility label for the repost button when the post has been reposted, verb followed by number of reposts and noun 9198 - #: src/components/PostControls/RepostButton.tsx:66 9355 + #: src/components/PostControls/RepostButton.tsx:67 9199 9356 msgid "Undo repost ({0, plural, one {# repost} other {# reposts}})" 9200 9357 msgstr "" 9201 9358 ··· 9217 9374 msgid "Unfollows the user" 9218 9375 msgstr "" 9219 9376 9220 - #: src/components/BlockedGeoOverlay.tsx:37 9377 + #: src/components/BlockedGeoOverlay.tsx:48 9221 9378 msgid "Unfortunately, Bluesky is unavailable in Mississippi right now." 9222 9379 msgstr "" 9223 9380 ··· 9234 9391 msgstr "" 9235 9392 9236 9393 #. Accessibility label for the like button when the post has been liked, verb followed by number of likes and noun 9237 - #: src/components/PostControls/index.tsx:243 9394 + #: src/components/PostControls/index.tsx:259 9238 9395 msgid "Unlike ({0, plural, one {# like} other {# likes}})" 9239 9396 msgstr "" 9240 9397 ··· 9535 9692 msgid "users following <0>@{0}</0>" 9536 9693 msgstr "" 9537 9694 9538 - #: src/screens/Messages/Settings.tsx:108 9539 - #: src/screens/Messages/Settings.tsx:111 9695 + #: src/screens/Messages/Settings.tsx:97 9696 + #: src/screens/Messages/Settings.tsx:100 9540 9697 msgid "Users I follow" 9541 9698 msgstr "" 9542 9699 ··· 9560 9717 msgid "Verification settings" 9561 9718 msgstr "" 9562 9719 9563 - #: src/Navigation.tsx:205 9720 + #: src/Navigation.tsx:206 9564 9721 #: src/screens/Moderation/VerificationSettings.tsx:32 9565 9722 msgid "Verification Settings" 9566 9723 msgstr "" ··· 9580 9737 msgid "Verify account" 9581 9738 msgstr "" 9582 9739 9583 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:122 9740 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:170 9584 9741 msgid "Verify again" 9585 9742 msgstr "" 9586 9743 ··· 9606 9763 msgid "Verify email dialog" 9607 9764 msgstr "" 9608 9765 9609 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:110 9610 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:124 9766 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:158 9767 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:172 9611 9768 msgid "Verify now" 9612 9769 msgstr "" 9613 9770 ··· 9652 9809 msgid "Video failed to process" 9653 9810 msgstr "" 9654 9811 9655 - #: src/Navigation.tsx:599 9812 + #: src/Navigation.tsx:600 9656 9813 msgid "Video Feed" 9657 9814 msgstr "" 9658 9815 ··· 9966 10123 9967 10124 #: src/screens/Signup/index.tsx:123 9968 10125 msgid "We're so excited to have you join us!" 10126 + msgstr "" 10127 + 10128 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:105 10129 + msgid "We're sorry, but based on your device's location, you are currently located in a region that requires age assurance." 10130 + msgstr "" 10131 + 10132 + #: src/components/BlockedGeoOverlay.tsx:159 10133 + msgid "We're sorry, but based on your device's location, you are currently located in a region where we cannot provide access at this time." 9969 10134 msgstr "" 9970 10135 9971 10136 #: src/view/screens/ProfileList.tsx:117 ··· 10056 10221 msgstr "" 10057 10222 10058 10223 #: src/screens/Home/NoFeedsPinned.tsx:79 10059 - #: src/screens/Messages/ChatList.tsx:258 10224 + #: src/screens/Messages/ChatList.tsx:269 10060 10225 #: src/screens/Messages/Inbox.tsx:197 10061 10226 msgid "Whoops!" 10062 10227 msgstr "" ··· 10177 10342 msgid "You are creating an account on" 10178 10343 msgstr "" 10179 10344 10180 - #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:80 10345 + #: src/components/ageAssurance/AgeAssuranceAccountCard.tsx:128 10181 10346 msgid "You are currently unable to access Bluesky's Age Assurance flow. Please <0>contact our moderation team</0> if you believe this is an error." 10182 10347 msgstr "" 10183 10348 ··· 10239 10404 msgid "You can choose whether chat notifications have sound in the chat settings within the app" 10240 10405 msgstr "" 10241 10406 10242 - #: src/screens/Messages/Settings.tsx:127 10407 + #: src/screens/Messages/Settings.tsx:116 10243 10408 msgid "You can continue ongoing conversations regardless of which setting you choose." 10244 10409 msgstr "" 10245 10410 ··· 10340 10505 msgid "You have muted this user" 10341 10506 msgstr "" 10342 10507 10343 - #: src/screens/Messages/ChatList.tsx:301 10508 + #: src/screens/Messages/ChatList.tsx:312 10344 10509 msgid "You have no conversations yet. Start one!" 10345 10510 msgstr "" 10346 10511 ··· 10426 10591 msgid "You must complete age assurance in order to access the settings below." 10427 10592 msgstr "" 10428 10593 10429 - #: src/components/ageAssurance/AgeRestrictedScreen.tsx:64 10594 + #: src/components/ageAssurance/AgeRestrictedScreen.tsx:66 10430 10595 msgid "You must complete age assurance in order to access this screen." 10431 10596 msgstr "" 10432 10597 ··· 10468 10633 msgstr "" 10469 10634 10470 10635 #: src/screens/Settings/Settings.tsx:286 10471 - #: src/view/shell/desktop/LeftNav.tsx:209 10636 + #: src/view/shell/desktop/LeftNav.tsx:210 10472 10637 msgid "You will be signed out of all your accounts." 10473 10638 msgstr "" 10474 10639 ··· 10659 10824 msgid "Your full handle will be <0>@{0}</0>" 10660 10825 msgstr "" 10661 10826 10662 - #: src/Navigation.tsx:515 10827 + #: src/Navigation.tsx:516 10663 10828 #: src/screens/Search/modules/ExploreInterestsCard.tsx:67 10664 10829 #: src/screens/Settings/ContentAndMediaSettings.tsx:92 10665 10830 #: src/screens/Settings/ContentAndMediaSettings.tsx:95 ··· 10674 10839 10675 10840 #: src/screens/Search/modules/ExploreInterestsCard.tsx:94 10676 10841 msgid "Your interests help us find what you like!" 10842 + msgstr "" 10843 + 10844 + #: src/components/dialogs/DeviceLocationRequestDialog.tsx:130 10845 + msgid "Your location data is not tracked and does not leave your device." 10677 10846 msgstr "" 10678 10847 10679 10848 #: src/components/dialogs/MutedWords.tsx:369
+13
src/logger/metrics.ts
··· 48 48 // Screen events 49 49 'splash:signInPressed': {} 50 50 'splash:createAccountPressed': {} 51 + 'welcomeModal:signupClicked': {} 52 + 'welcomeModal:exploreClicked': {} 53 + 'welcomeModal:signinClicked': {} 54 + 'welcomeModal:dismissed': {} 55 + 'welcomeModal:presented': {} 51 56 'signup:nextPressed': { 52 57 activeStep: number 53 58 phoneVerificationRequired?: boolean ··· 233 238 'post:unmute': {} 234 239 'post:pin': {} 235 240 'post:unpin': {} 241 + 'post:bookmark': { 242 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 243 + } 244 + 'post:unbookmark': { 245 + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' 246 + } 247 + 'bookmarks:view': {} 248 + 'bookmarks:post-clicked': {} 236 249 'profile:follow': { 237 250 didBecomeMutual: boolean | undefined 238 251 followeeClout: number | undefined
+1
src/logger/types.ts
··· 14 14 PostSource = 'post-source', 15 15 AgeAssurance = 'age-assurance', 16 16 PolicyUpdate = 'policy-update', 17 + Geolocation = 'geolocation', 17 18 18 19 /** 19 20 * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this
+1
src/routes.ts
··· 91 91 StarterPackShort: '/starter-pack-short/:code', 92 92 StarterPackWizard: '/starter-pack/create', 93 93 VideoFeed: '/video-feed', 94 + Bookmarks: '/saved', 94 95 })
+59
src/screens/Bookmarks/components/EmptyState.tsx
··· 1 + import {View} from 'react-native' 2 + import {msg, Trans} from '@lingui/macro' 3 + import {useLingui} from '@lingui/react' 4 + 5 + import {atoms as a, useTheme} from '#/alf' 6 + import {ButtonText} from '#/components/Button' 7 + import {BookmarkDeleteLarge} from '#/components/icons/Bookmark' 8 + import {Link} from '#/components/Link' 9 + import {Text} from '#/components/Typography' 10 + 11 + export function EmptyState() { 12 + const t = useTheme() 13 + const {_} = useLingui() 14 + 15 + return ( 16 + <View 17 + style={[ 18 + a.align_center, 19 + { 20 + paddingVertical: 64, 21 + }, 22 + ]}> 23 + <BookmarkDeleteLarge 24 + width={64} 25 + fill={t.atoms.text_contrast_medium.color} 26 + /> 27 + <View style={[a.pt_sm]}> 28 + <Text 29 + style={[ 30 + a.text_lg, 31 + a.font_medium, 32 + a.text_center, 33 + t.atoms.text_contrast_medium, 34 + ]}> 35 + <Trans>Nothing saved yet</Trans> 36 + </Text> 37 + </View> 38 + <View style={[a.pt_2xl]}> 39 + <Link 40 + to="/" 41 + action="navigate" 42 + label={_( 43 + msg({ 44 + message: `Go home`, 45 + context: `Button to go back to the home timeline`, 46 + }), 47 + )} 48 + size="small" 49 + color="secondary"> 50 + <ButtonText> 51 + <Trans context="Button to go back to the home timeline"> 52 + Go home 53 + </Trans> 54 + </ButtonText> 55 + </Link> 56 + </View> 57 + </View> 58 + ) 59 + }
+294
src/screens/Bookmarks/index.tsx
··· 1 + import {useCallback, useMemo, useState} from 'react' 2 + import {View} from 'react-native' 3 + import { 4 + type $Typed, 5 + type AppBskyBookmarkDefs, 6 + AppBskyFeedDefs, 7 + } from '@atproto/api' 8 + import {msg, Trans} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + import {useFocusEffect} from '@react-navigation/native' 11 + 12 + import {useCleanError} from '#/lib/hooks/useCleanError' 13 + import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 14 + import { 15 + type CommonNavigatorParams, 16 + type NativeStackScreenProps, 17 + } from '#/lib/routes/types' 18 + import {logger} from '#/logger' 19 + import {isIOS} from '#/platform/detection' 20 + import {useBookmarkMutation} from '#/state/queries/bookmarks/useBookmarkMutation' 21 + import {useBookmarksQuery} from '#/state/queries/bookmarks/useBookmarksQuery' 22 + import {useSetMinimalShellMode} from '#/state/shell' 23 + import {Post} from '#/view/com/post/Post' 24 + import {List} from '#/view/com/util/List' 25 + import {PostFeedLoadingPlaceholder} from '#/view/com/util/LoadingPlaceholder' 26 + import {EmptyState} from '#/screens/Bookmarks/components/EmptyState' 27 + import {atoms as a, useTheme} from '#/alf' 28 + import {Button, ButtonIcon, ButtonText} from '#/components/Button' 29 + import {BookmarkFilled} from '#/components/icons/Bookmark' 30 + import {CircleQuestion_Stroke2_Corner2_Rounded as QuestionIcon} from '#/components/icons/CircleQuestion' 31 + import * as Layout from '#/components/Layout' 32 + import {ListFooter} from '#/components/Lists' 33 + import * as Skele from '#/components/Skeleton' 34 + import * as toast from '#/components/Toast' 35 + import {Text} from '#/components/Typography' 36 + 37 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'Bookmarks'> 38 + 39 + export function BookmarksScreen({}: Props) { 40 + const setMinimalShellMode = useSetMinimalShellMode() 41 + 42 + useFocusEffect( 43 + useCallback(() => { 44 + setMinimalShellMode(false) 45 + logger.metric('bookmarks:view', {}) 46 + }, [setMinimalShellMode]), 47 + ) 48 + 49 + return ( 50 + <Layout.Screen testID="bookmarksScreen"> 51 + <Layout.Header.Outer> 52 + <Layout.Header.BackButton /> 53 + <Layout.Header.Content> 54 + <Layout.Header.TitleText> 55 + <Trans>Saved Posts</Trans> 56 + </Layout.Header.TitleText> 57 + </Layout.Header.Content> 58 + <Layout.Header.Slot /> 59 + </Layout.Header.Outer> 60 + <BookmarksInner /> 61 + </Layout.Screen> 62 + ) 63 + } 64 + 65 + type ListItem = 66 + | { 67 + type: 'loading' 68 + key: 'loading' 69 + } 70 + | { 71 + type: 'empty' 72 + key: 'empty' 73 + } 74 + | { 75 + type: 'bookmark' 76 + key: string 77 + bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & { 78 + item: $Typed<AppBskyFeedDefs.PostView> 79 + } 80 + } 81 + | { 82 + type: 'bookmarkNotFound' 83 + key: string 84 + bookmark: Omit<AppBskyBookmarkDefs.BookmarkView, 'item'> & { 85 + item: $Typed<AppBskyFeedDefs.NotFoundPost> 86 + } 87 + } 88 + 89 + function BookmarksInner() { 90 + const initialNumToRender = useInitialNumToRender() 91 + const cleanError = useCleanError() 92 + const [isPTRing, setIsPTRing] = useState(false) 93 + const { 94 + data, 95 + isLoading, 96 + isFetchingNextPage, 97 + hasNextPage, 98 + fetchNextPage, 99 + error, 100 + refetch, 101 + } = useBookmarksQuery() 102 + const cleanedError = useMemo(() => { 103 + const {raw, clean} = cleanError(error) 104 + return clean || raw 105 + }, [error, cleanError]) 106 + 107 + const onRefresh = useCallback(async () => { 108 + setIsPTRing(true) 109 + try { 110 + await refetch() 111 + } finally { 112 + setIsPTRing(false) 113 + } 114 + }, [refetch, setIsPTRing]) 115 + 116 + const onEndReached = useCallback(async () => { 117 + if (isFetchingNextPage || !hasNextPage || error) return 118 + try { 119 + await fetchNextPage() 120 + } catch {} 121 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 122 + 123 + const items = useMemo(() => { 124 + const i: ListItem[] = [] 125 + 126 + if (isLoading) { 127 + i.push({type: 'loading', key: 'loading'}) 128 + } else if (error || !data) { 129 + // handled in Footer 130 + } else { 131 + const bookmarks = data.pages.flatMap(p => p.bookmarks) 132 + 133 + if (bookmarks.length > 0) { 134 + for (const bookmark of bookmarks) { 135 + if (AppBskyFeedDefs.isNotFoundPost(bookmark.item)) { 136 + i.push({ 137 + type: 'bookmarkNotFound', 138 + key: bookmark.item.uri, 139 + bookmark: { 140 + ...bookmark, 141 + item: bookmark.item as $Typed<AppBskyFeedDefs.NotFoundPost>, 142 + }, 143 + }) 144 + } 145 + if (AppBskyFeedDefs.isPostView(bookmark.item)) { 146 + i.push({ 147 + type: 'bookmark', 148 + key: bookmark.item.uri, 149 + bookmark: { 150 + ...bookmark, 151 + item: bookmark.item as $Typed<AppBskyFeedDefs.PostView>, 152 + }, 153 + }) 154 + } 155 + } 156 + } else { 157 + i.push({type: 'empty', key: 'empty'}) 158 + } 159 + } 160 + 161 + return i 162 + }, [isLoading, error, data]) 163 + 164 + const isEmpty = items.length === 1 && items[0]?.type === 'empty' 165 + 166 + return ( 167 + <List 168 + data={items} 169 + renderItem={renderItem} 170 + keyExtractor={keyExtractor} 171 + refreshing={isPTRing} 172 + onRefresh={onRefresh} 173 + onEndReached={onEndReached} 174 + onEndReachedThreshold={4} 175 + ListFooterComponent={ 176 + <ListFooter 177 + isFetchingNextPage={isFetchingNextPage} 178 + error={cleanedError} 179 + onRetry={fetchNextPage} 180 + style={[isEmpty && a.border_t_0]} 181 + /> 182 + } 183 + initialNumToRender={initialNumToRender} 184 + windowSize={9} 185 + maxToRenderPerBatch={isIOS ? 5 : 1} 186 + updateCellsBatchingPeriod={40} 187 + sideBorders={false} 188 + /> 189 + ) 190 + } 191 + 192 + function BookmarkNotFound({ 193 + hideTopBorder, 194 + post, 195 + }: { 196 + hideTopBorder: boolean 197 + post: $Typed<AppBskyFeedDefs.NotFoundPost> 198 + }) { 199 + const t = useTheme() 200 + const {_} = useLingui() 201 + const {mutateAsync: bookmark} = useBookmarkMutation() 202 + const cleanError = useCleanError() 203 + 204 + const remove = async () => { 205 + try { 206 + await bookmark({action: 'delete', uri: post.uri}) 207 + toast.show(_(msg`Removed from saved posts`), { 208 + type: 'info', 209 + }) 210 + } catch (e: any) { 211 + const {raw, clean} = cleanError(e) 212 + toast.show(clean || raw || e, { 213 + type: 'error', 214 + }) 215 + } 216 + } 217 + 218 + return ( 219 + <View 220 + style={[ 221 + a.flex_row, 222 + a.align_start, 223 + a.px_xl, 224 + a.py_lg, 225 + a.gap_sm, 226 + !hideTopBorder && a.border_t, 227 + t.atoms.border_contrast_low, 228 + ]}> 229 + <Skele.Circle size={42}> 230 + <QuestionIcon size="lg" fill={t.atoms.text_contrast_low.color} /> 231 + </Skele.Circle> 232 + <View style={[a.flex_1, a.gap_2xs]}> 233 + <View style={[a.flex_row, a.gap_xs]}> 234 + <Skele.Text style={[a.text_md, {width: 80}]} /> 235 + <Skele.Text style={[a.text_md, {width: 100}]} /> 236 + </View> 237 + 238 + <Text 239 + style={[ 240 + a.text_md, 241 + a.leading_snug, 242 + a.italic, 243 + t.atoms.text_contrast_medium, 244 + ]}> 245 + <Trans>This post was deleted by its author</Trans> 246 + </Text> 247 + </View> 248 + <Button 249 + label={_(msg`Remove from saved posts`)} 250 + size="tiny" 251 + color="secondary" 252 + onPress={remove}> 253 + <ButtonIcon icon={BookmarkFilled} /> 254 + <ButtonText> 255 + <Trans>Remove</Trans> 256 + </ButtonText> 257 + </Button> 258 + </View> 259 + ) 260 + } 261 + 262 + function renderItem({item, index}: {item: ListItem; index: number}) { 263 + switch (item.type) { 264 + case 'loading': { 265 + return <PostFeedLoadingPlaceholder /> 266 + } 267 + case 'empty': { 268 + return <EmptyState /> 269 + } 270 + case 'bookmark': { 271 + return ( 272 + <Post 273 + post={item.bookmark.item} 274 + hideTopBorder={index === 0} 275 + onBeforePress={() => { 276 + logger.metric('bookmarks:post-clicked', {}) 277 + }} 278 + /> 279 + ) 280 + } 281 + case 'bookmarkNotFound': { 282 + return ( 283 + <BookmarkNotFound 284 + post={item.bookmark.item} 285 + hideTopBorder={index === 0} 286 + /> 287 + ) 288 + } 289 + default: 290 + return null 291 + } 292 + } 293 + 294 + const keyExtractor = (item: ListItem) => item.key
+12 -1
src/screens/Messages/ChatList.tsx
··· 74 74 return ( 75 75 <AgeRestrictedScreen 76 76 screenTitle={_(msg`Chats`)} 77 - infoText={aaCopy.chatsInfoText}> 77 + infoText={aaCopy.chatsInfoText} 78 + rightHeaderSlot={ 79 + <Link 80 + to="/messages/settings" 81 + label={_(msg`Chat settings`)} 82 + size="small" 83 + color="secondary"> 84 + <ButtonText> 85 + <Trans>Chat settings</Trans> 86 + </ButtonText> 87 + </Link> 88 + }> 78 89 <MessagesScreenInner {...props} /> 79 90 </AgeRestrictedScreen> 80 91 )
+1 -12
src/screens/Messages/Settings.tsx
··· 12 12 import * as Toast from '#/view/com/util/Toast' 13 13 import {atoms as a} from '#/alf' 14 14 import {Admonition} from '#/components/Admonition' 15 - import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen' 16 - import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 17 15 import {Divider} from '#/components/Divider' 18 16 import * as Toggle from '#/components/forms/Toggle' 19 17 import * as Layout from '#/components/Layout' ··· 25 23 type Props = NativeStackScreenProps<CommonNavigatorParams, 'MessagesSettings'> 26 24 27 25 export function MessagesSettingsScreen(props: Props) { 28 - const {_} = useLingui() 29 - const aaCopy = useAgeAssuranceCopy() 30 - 31 - return ( 32 - <AgeRestrictedScreen 33 - screenTitle={_(msg`Chat Settings`)} 34 - infoText={aaCopy.chatsInfoText}> 35 - <MessagesSettingsScreenInner {...props} /> 36 - </AgeRestrictedScreen> 37 - ) 26 + return <MessagesSettingsScreenInner {...props} /> 38 27 } 39 28 40 29 export function MessagesSettingsScreenInner({}: Props) {
+29 -7
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 32 32 import {type OnPostSuccessData} from '#/state/shell/composer' 33 33 import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' 34 34 import {type PostSource} from '#/state/unstable-post-source' 35 - import {formatCount} from '#/view/com/util/numeric/format' 36 35 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 37 36 import {ThreadItemAnchorFollowButton} from '#/screens/PostThread/components/ThreadItemAnchorFollowButton' 38 37 import { ··· 53 52 import {type AppModerationCause} from '#/components/Pills' 54 53 import {Embed, PostEmbedViewContext} from '#/components/Post/Embed' 55 54 import {PostControls} from '#/components/PostControls' 55 + import {useFormatPostStatCount} from '#/components/PostControls/util' 56 56 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 57 57 import * as Prompt from '#/components/Prompt' 58 58 import {RichText} from '#/components/RichText' ··· 176 176 postSource?: PostSource 177 177 }) { 178 178 const t = useTheme() 179 - const {_, i18n} = useLingui() 179 + const {_} = useLingui() 180 180 const {openComposer} = useOpenComposer() 181 181 const {currentAccount, hasSession} = useSession() 182 182 const {gtTablet} = useBreakpoints() 183 183 const feedFeedback = useFeedFeedback(postSource?.feedSourceInfo, hasSession) 184 + const formatPostStatCount = useFormatPostStatCount() 184 185 185 186 const post = postShadow 186 187 const record = item.value.post.record ··· 415 416 /> 416 417 {post.repostCount !== 0 || 417 418 post.likeCount !== 0 || 418 - post.quoteCount !== 0 ? ( 419 + post.quoteCount !== 0 || 420 + post.bookmarkCount !== 0 ? ( 419 421 // Show this section unless we're *sure* it has no engagement. 420 422 <View 421 423 style={[ 422 424 a.flex_row, 425 + a.flex_wrap, 423 426 a.align_center, 424 - a.gap_lg, 427 + { 428 + rowGap: a.gap_sm.gap, 429 + columnGap: a.gap_lg.gap, 430 + }, 425 431 a.border_t, 426 432 a.border_b, 427 433 a.mt_md, ··· 434 440 testID="repostCount-expanded" 435 441 style={[a.text_md, t.atoms.text_contrast_medium]}> 436 442 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 437 - {formatCount(i18n, post.repostCount)} 443 + {formatPostStatCount(post.repostCount)} 438 444 </Text>{' '} 439 445 <Plural 440 446 value={post.repostCount} ··· 452 458 testID="quoteCount-expanded" 453 459 style={[a.text_md, t.atoms.text_contrast_medium]}> 454 460 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 455 - {formatCount(i18n, post.quoteCount)} 461 + {formatPostStatCount(post.quoteCount)} 456 462 </Text>{' '} 457 463 <Plural 458 464 value={post.quoteCount} ··· 468 474 testID="likeCount-expanded" 469 475 style={[a.text_md, t.atoms.text_contrast_medium]}> 470 476 <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 471 - {formatCount(i18n, post.likeCount)} 477 + {formatPostStatCount(post.likeCount)} 472 478 </Text>{' '} 473 479 <Plural value={post.likeCount} one="like" other="likes" /> 480 + </Text> 481 + </Link> 482 + ) : null} 483 + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 484 + <Link to={likesHref} label={_(msg`Saves of this post`)}> 485 + <Text 486 + testID="bookmarkCount-expanded" 487 + style={[a.text_md, t.atoms.text_contrast_medium]}> 488 + <Text style={[a.text_md, a.font_bold, t.atoms.text]}> 489 + {formatPostStatCount(post.bookmarkCount)} 490 + </Text>{' '} 491 + <Plural 492 + value={post.bookmarkCount} 493 + one="save" 494 + other="saves" 495 + /> 474 496 </Text> 475 497 </Link> 476 498 ) : null}
+1
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 368 368 </View> 369 369 )} 370 370 <PostControls 371 + variant="compact" 371 372 post={postShadow} 372 373 record={record} 373 374 richText={richText}
+2 -2
src/state/ageAssurance/index.tsx
··· 11 11 } from '#/state/ageAssurance/types' 12 12 import {useIsAgeAssuranceEnabled} from '#/state/ageAssurance/useIsAgeAssuranceEnabled' 13 13 import {logger} from '#/state/ageAssurance/util' 14 - import {useGeolocation} from '#/state/geolocation' 14 + import {useGeolocationStatus} from '#/state/geolocation' 15 15 import {useAgent} from '#/state/session' 16 16 17 17 export const createAgeAssuranceQueryKey = (did: string) => ··· 43 43 */ 44 44 export function Provider({children}: {children: React.ReactNode}) { 45 45 const agent = useAgent() 46 - const {geolocation} = useGeolocation() 46 + const {status: geolocation} = useGeolocationStatus() 47 47 const isAgeAssuranceEnabled = useIsAgeAssuranceEnabled() 48 48 const getAndRegisterPushToken = useGetAndRegisterPushToken() 49 49 const [refetchWhilePending, setRefetchWhilePending] = useState(false)
+2 -2
src/state/ageAssurance/useInitAgeAssurance.ts
··· 14 14 import {isNetworkError} from '#/lib/hooks/useCleanError' 15 15 import {logger} from '#/logger' 16 16 import {createAgeAssuranceQueryKey} from '#/state/ageAssurance' 17 - import {useGeolocation} from '#/state/geolocation' 17 + import {useGeolocationStatus} from '#/state/geolocation' 18 18 import {useAgent} from '#/state/session' 19 19 20 20 let APPVIEW = PUBLIC_APPVIEW ··· 36 36 export function useInitAgeAssurance() { 37 37 const qc = useQueryClient() 38 38 const agent = useAgent() 39 - const {geolocation} = useGeolocation() 39 + const {status: geolocation} = useGeolocationStatus() 40 40 return useMutation({ 41 41 async mutationFn( 42 42 props: Omit<AppBskyUnspeccedInitAgeAssurance.InputSchema, 'countryCode'>,
+2 -2
src/state/ageAssurance/useIsAgeAssuranceEnabled.ts
··· 1 1 import {useMemo} from 'react' 2 2 3 - import {useGeolocation} from '#/state/geolocation' 3 + import {useGeolocationStatus} from '#/state/geolocation' 4 4 5 5 export function useIsAgeAssuranceEnabled() { 6 - const {geolocation} = useGeolocation() 6 + const {status: geolocation} = useGeolocationStatus() 7 7 8 8 return useMemo(() => { 9 9 return !!geolocation?.isAgeRestrictedGeo
+16
src/state/cache/post-shadow.ts
··· 25 25 embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined 26 26 pinned: boolean 27 27 optimisticReplyCount: number | undefined 28 + bookmarked: boolean | undefined 28 29 } 29 30 30 31 export const POST_TOMBSTONE = Symbol('PostTombstone') ··· 92 93 likeCount = Math.max(0, likeCount) 93 94 } 94 95 96 + let bookmarkCount = post.bookmarkCount ?? 0 97 + if ('bookmarked' in shadow) { 98 + const wasBookmarked = !!post.viewer?.bookmarked 99 + const isBookmarked = !!shadow.bookmarked 100 + if (wasBookmarked && !isBookmarked) { 101 + bookmarkCount-- 102 + } else if (!wasBookmarked && isBookmarked) { 103 + bookmarkCount++ 104 + } 105 + bookmarkCount = Math.max(0, bookmarkCount) 106 + } 107 + 95 108 let repostCount = post.repostCount ?? 0 96 109 if ('repostUri' in shadow) { 97 110 const wasReposted = !!post.viewer?.repost ··· 127 140 likeCount: likeCount, 128 141 repostCount: repostCount, 129 142 replyCount: replyCount, 143 + bookmarkCount: bookmarkCount, 130 144 viewer: { 131 145 ...(post.viewer || {}), 132 146 like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, 133 147 repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, 134 148 pinned: 'pinned' in shadow ? shadow.pinned : post.viewer?.pinned, 149 + bookmarked: 150 + 'bookmarked' in shadow ? shadow.bookmarked : post.viewer?.bookmarked, 135 151 }, 136 152 }) 137 153 }
-227
src/state/geolocation.tsx
··· 1 - import React from 'react' 2 - import EventEmitter from 'eventemitter3' 3 - 4 - import {networkRetry} from '#/lib/async/retry' 5 - import {logger} from '#/logger' 6 - import {type Device, device} from '#/storage' 7 - 8 - const IPCC_URL = `https://bsky.app/ipcc` 9 - const BAPP_CONFIG_URL = `https://ip.bsky.app/config` 10 - 11 - const events = new EventEmitter() 12 - const EVENT = 'geolocation-updated' 13 - const emitGeolocationUpdate = (geolocation: Device['geolocation']) => { 14 - events.emit(EVENT, geolocation) 15 - } 16 - const onGeolocationUpdate = ( 17 - listener: (geolocation: Device['geolocation']) => void, 18 - ) => { 19 - events.on(EVENT, listener) 20 - return () => { 21 - events.off(EVENT, listener) 22 - } 23 - } 24 - 25 - /** 26 - * Default geolocation value. IF undefined, we fail closed and apply all 27 - * additional mod authorities. 28 - */ 29 - export const DEFAULT_GEOLOCATION: Device['geolocation'] = { 30 - countryCode: undefined, 31 - isAgeBlockedGeo: undefined, 32 - isAgeRestrictedGeo: false, 33 - } 34 - 35 - function sanitizeGeolocation( 36 - geolocation: Device['geolocation'], 37 - ): Device['geolocation'] { 38 - return { 39 - countryCode: geolocation?.countryCode ?? undefined, 40 - isAgeBlockedGeo: geolocation?.isAgeBlockedGeo ?? false, 41 - isAgeRestrictedGeo: geolocation?.isAgeRestrictedGeo ?? false, 42 - } 43 - } 44 - 45 - async function getGeolocation(url: string): Promise<Device['geolocation']> { 46 - const res = await fetch(url) 47 - 48 - if (!res.ok) { 49 - throw new Error(`geolocation: lookup failed ${res.status}`) 50 - } 51 - 52 - const json = await res.json() 53 - 54 - if (json.countryCode) { 55 - return { 56 - countryCode: json.countryCode, 57 - isAgeBlockedGeo: json.isAgeBlockedGeo ?? false, 58 - isAgeRestrictedGeo: json.isAgeRestrictedGeo ?? false, 59 - // @ts-ignore 60 - regionCode: json.regionCode ?? undefined, 61 - } 62 - } else { 63 - return undefined 64 - } 65 - } 66 - 67 - async function compareWithIPCC(bapp: Device['geolocation']) { 68 - try { 69 - const ipcc = await getGeolocation(IPCC_URL) 70 - 71 - if (!ipcc || !bapp) return 72 - 73 - logger.metric( 74 - 'geo:debug', 75 - { 76 - bappCountryCode: bapp.countryCode, 77 - // @ts-ignore 78 - bappRegionCode: bapp.regionCode, 79 - bappIsAgeBlockedGeo: bapp.isAgeBlockedGeo, 80 - bappIsAgeRestrictedGeo: bapp.isAgeRestrictedGeo, 81 - ipccCountryCode: ipcc.countryCode, 82 - ipccIsAgeBlockedGeo: ipcc.isAgeBlockedGeo, 83 - ipccIsAgeRestrictedGeo: ipcc.isAgeRestrictedGeo, 84 - }, 85 - { 86 - statsig: false, 87 - }, 88 - ) 89 - } catch {} 90 - } 91 - 92 - /** 93 - * Local promise used within this file only. 94 - */ 95 - let geolocationResolution: Promise<{success: boolean}> | undefined 96 - 97 - /** 98 - * Begin the process of resolving geolocation. This should be called once at 99 - * app start. 100 - * 101 - * THIS METHOD SHOULD NEVER THROW. 102 - * 103 - * This method is otherwise not used for any purpose. To ensure geolocation is 104 - * resolved, use {@link ensureGeolocationResolved} 105 - */ 106 - export function beginResolveGeolocation() { 107 - /** 108 - * In dev, IP server is unavailable, so we just set the default geolocation 109 - * and fail closed. 110 - */ 111 - // commented out the dev if check, the entire web ui straight up doesnt load when doing build-web because of this check 112 - //if (__DEV__) { 113 - geolocationResolution = new Promise(y => y({success: true})) 114 - if (!device.get(['geolocation'])) { 115 - device.set(['geolocation'], DEFAULT_GEOLOCATION) 116 - // } 117 - return 118 - } 119 - 120 - geolocationResolution = new Promise(async resolve => { 121 - let success = true 122 - 123 - try { 124 - // Try once, fail fast 125 - const geolocation = await getGeolocation(BAPP_CONFIG_URL) 126 - if (geolocation) { 127 - device.set(['geolocation'], sanitizeGeolocation(geolocation)) 128 - emitGeolocationUpdate(geolocation) 129 - logger.debug(`geolocation: success`, {geolocation}) 130 - compareWithIPCC(geolocation) 131 - } else { 132 - // endpoint should throw on all failures, this is insurance 133 - throw new Error(`geolocation: nothing returned from initial request`) 134 - } 135 - } catch (e: any) { 136 - success = false 137 - 138 - logger.debug(`geolocation: failed initial request`, { 139 - safeMessage: e.message, 140 - }) 141 - 142 - // set to default 143 - device.set(['geolocation'], DEFAULT_GEOLOCATION) 144 - 145 - // retry 3 times, but don't await, proceed with default 146 - networkRetry(3, () => getGeolocation(BAPP_CONFIG_URL)) 147 - .then(geolocation => { 148 - if (geolocation) { 149 - device.set(['geolocation'], sanitizeGeolocation(geolocation)) 150 - emitGeolocationUpdate(geolocation) 151 - logger.debug(`geolocation: success`, {geolocation}) 152 - success = true 153 - compareWithIPCC(geolocation) 154 - } else { 155 - // endpoint should throw on all failures, this is insurance 156 - throw new Error(`geolocation: nothing returned from retries`) 157 - } 158 - }) 159 - .catch((e: any) => { 160 - // complete fail closed 161 - logger.debug(`geolocation: failed retries`, {safeMessage: e.message}) 162 - }) 163 - } finally { 164 - resolve({success}) 165 - } 166 - }) 167 - } 168 - 169 - /** 170 - * Ensure that geolocation has been resolved, or at the very least attempted 171 - * once. Subsequent retries will not be captured by this `await`. Those will be 172 - * reported via {@link events}. 173 - */ 174 - export async function ensureGeolocationResolved() { 175 - if (!geolocationResolution) { 176 - throw new Error(`geolocation: beginResolveGeolocation not called yet`) 177 - } 178 - 179 - const cached = device.get(['geolocation']) 180 - if (cached) { 181 - logger.debug(`geolocation: using cache`, {cached}) 182 - } else { 183 - logger.debug(`geolocation: no cache`) 184 - const {success} = await geolocationResolution 185 - if (success) { 186 - logger.debug(`geolocation: resolved`, { 187 - resolved: device.get(['geolocation']), 188 - }) 189 - } else { 190 - logger.error(`geolocation: failed to resolve`) 191 - } 192 - } 193 - } 194 - 195 - type Context = { 196 - geolocation: Device['geolocation'] 197 - } 198 - 199 - const context = React.createContext<Context>({ 200 - geolocation: DEFAULT_GEOLOCATION, 201 - }) 202 - context.displayName = 'GeolocationContext' 203 - 204 - export function Provider({children}: {children: React.ReactNode}) { 205 - const [geolocation, setGeolocation] = React.useState(() => { 206 - const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION 207 - return initial 208 - }) 209 - 210 - React.useEffect(() => { 211 - return onGeolocationUpdate(geolocation => { 212 - setGeolocation(geolocation!) 213 - }) 214 - }, []) 215 - 216 - const ctx = React.useMemo(() => { 217 - return { 218 - geolocation, 219 - } 220 - }, [geolocation]) 221 - 222 - return <context.Provider value={ctx}>{children}</context.Provider> 223 - } 224 - 225 - export function useGeolocation() { 226 - return React.useContext(context) 227 - }
+143
src/state/geolocation/config.ts
··· 1 + import {networkRetry} from '#/lib/async/retry' 2 + import { 3 + DEFAULT_GEOLOCATION_CONFIG, 4 + GEOLOCATION_CONFIG_URL, 5 + } from '#/state/geolocation/const' 6 + import {emitGeolocationConfigUpdate} from '#/state/geolocation/events' 7 + import {logger} from '#/state/geolocation/logger' 8 + import {BAPP_CONFIG_DEV_BYPASS_SECRET, IS_DEV} from '#/env' 9 + import {type Device, device} from '#/storage' 10 + 11 + async function getGeolocationConfig( 12 + url: string, 13 + ): Promise<Device['geolocation']> { 14 + const res = await fetch(url, { 15 + headers: IS_DEV 16 + ? { 17 + 'x-dev-bypass-secret': BAPP_CONFIG_DEV_BYPASS_SECRET, 18 + } 19 + : undefined, 20 + }) 21 + 22 + if (!res.ok) { 23 + throw new Error(`geolocation config: fetch failed ${res.status}`) 24 + } 25 + 26 + const json = await res.json() 27 + 28 + if (json.countryCode) { 29 + /** 30 + * Only construct known values here, ignore any extras. 31 + */ 32 + const config: Device['geolocation'] = { 33 + countryCode: json.countryCode, 34 + regionCode: json.regionCode ?? undefined, 35 + ageRestrictedGeos: json.ageRestrictedGeos ?? [], 36 + ageBlockedGeos: json.ageBlockedGeos ?? [], 37 + } 38 + logger.debug(`geolocation config: success`) 39 + return config 40 + } else { 41 + return undefined 42 + } 43 + } 44 + 45 + /** 46 + * Local promise used within this file only. 47 + */ 48 + let geolocationConfigResolution: Promise<{success: boolean}> | undefined 49 + 50 + /** 51 + * Begin the process of resolving geolocation config. This should be called 52 + * once at app start. 53 + * 54 + * THIS METHOD SHOULD NEVER THROW. 55 + * 56 + * This method is otherwise not used for any purpose. To ensure geolocation 57 + * config is resolved, use {@link ensureGeolocationConfigIsResolved} 58 + */ 59 + export function beginResolveGeolocationConfig() { 60 + /** 61 + * Here for debug purposes. Uncomment to prevent hitting the remote geo service, and apply whatever data you require for testing. 62 + */ 63 + // if (__DEV__) { 64 + // geolocationConfigResolution = new Promise(y => y({success: true})) 65 + // device.set(['deviceGeolocation'], undefined) // clears GPS data 66 + // device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) // clears bapp-config data 67 + // return 68 + // } 69 + 70 + geolocationConfigResolution = new Promise(async resolve => { 71 + let success = true 72 + 73 + try { 74 + // Try once, fail fast 75 + const config = await getGeolocationConfig(GEOLOCATION_CONFIG_URL) 76 + if (config) { 77 + device.set(['geolocation'], config) 78 + emitGeolocationConfigUpdate(config) 79 + } else { 80 + // endpoint should throw on all failures, this is insurance 81 + throw new Error( 82 + `geolocation config: nothing returned from initial request`, 83 + ) 84 + } 85 + } catch (e: any) { 86 + success = false 87 + 88 + logger.debug(`geolocation config: failed initial request`, { 89 + safeMessage: e.message, 90 + }) 91 + 92 + // set to default 93 + device.set(['geolocation'], DEFAULT_GEOLOCATION_CONFIG) 94 + 95 + // retry 3 times, but don't await, proceed with default 96 + networkRetry(3, () => getGeolocationConfig(GEOLOCATION_CONFIG_URL)) 97 + .then(config => { 98 + if (config) { 99 + device.set(['geolocation'], config) 100 + emitGeolocationConfigUpdate(config) 101 + success = true 102 + } else { 103 + // endpoint should throw on all failures, this is insurance 104 + throw new Error(`geolocation config: nothing returned from retries`) 105 + } 106 + }) 107 + .catch((e: any) => { 108 + // complete fail closed 109 + logger.debug(`geolocation config: failed retries`, { 110 + safeMessage: e.message, 111 + }) 112 + }) 113 + } finally { 114 + resolve({success}) 115 + } 116 + }) 117 + } 118 + 119 + /** 120 + * Ensure that geolocation config has been resolved, or at the very least attempted 121 + * once. Subsequent retries will not be captured by this `await`. Those will be 122 + * reported via {@link emitGeolocationConfigUpdate}. 123 + */ 124 + export async function ensureGeolocationConfigIsResolved() { 125 + if (!geolocationConfigResolution) { 126 + throw new Error( 127 + `geolocation config: beginResolveGeolocationConfig not called yet`, 128 + ) 129 + } 130 + 131 + const cached = device.get(['geolocation']) 132 + if (cached) { 133 + logger.debug(`geolocation config: using cache`) 134 + } else { 135 + logger.debug(`geolocation config: no cache`) 136 + const {success} = await geolocationConfigResolution 137 + if (success) { 138 + logger.debug(`geolocation config: resolved`) 139 + } else { 140 + logger.info(`geolocation config: failed to resolve`) 141 + } 142 + } 143 + }
+30
src/state/geolocation/const.ts
··· 1 + import {type GeolocationStatus} from '#/state/geolocation/types' 2 + import {BAPP_CONFIG_DEV_URL, IS_DEV} from '#/env' 3 + import {type Device} from '#/storage' 4 + 5 + export const IPCC_URL = `https://bsky.app/ipcc` 6 + export const BAPP_CONFIG_URL_PROD = `https://ip.bsky.app/config` 7 + export const BAPP_CONFIG_URL = IS_DEV 8 + ? (BAPP_CONFIG_DEV_URL ?? BAPP_CONFIG_URL_PROD) 9 + : BAPP_CONFIG_URL_PROD 10 + export const GEOLOCATION_CONFIG_URL = BAPP_CONFIG_URL 11 + 12 + /** 13 + * Default geolocation config. 14 + */ 15 + export const DEFAULT_GEOLOCATION_CONFIG: Device['geolocation'] = { 16 + countryCode: undefined, 17 + regionCode: undefined, 18 + ageRestrictedGeos: [], 19 + ageBlockedGeos: [], 20 + } 21 + 22 + /** 23 + * Default geolocation status. 24 + */ 25 + export const DEFAULT_GEOLOCATION_STATUS: GeolocationStatus = { 26 + countryCode: undefined, 27 + regionCode: undefined, 28 + isAgeRestrictedGeo: false, 29 + isAgeBlockedGeo: false, 30 + }
+19
src/state/geolocation/events.ts
··· 1 + import EventEmitter from 'eventemitter3' 2 + 3 + import {type Device} from '#/storage' 4 + 5 + const events = new EventEmitter() 6 + const EVENT = 'geolocation-config-updated' 7 + 8 + export const emitGeolocationConfigUpdate = (config: Device['geolocation']) => { 9 + events.emit(EVENT, config) 10 + } 11 + 12 + export const onGeolocationConfigUpdate = ( 13 + listener: (config: Device['geolocation']) => void, 14 + ) => { 15 + events.on(EVENT, listener) 16 + return () => { 17 + events.off(EVENT, listener) 18 + } 19 + }
+153
src/state/geolocation/index.tsx
··· 1 + import React from 'react' 2 + 3 + import { 4 + DEFAULT_GEOLOCATION_CONFIG, 5 + DEFAULT_GEOLOCATION_STATUS, 6 + } from '#/state/geolocation/const' 7 + import {onGeolocationConfigUpdate} from '#/state/geolocation/events' 8 + import {logger} from '#/state/geolocation/logger' 9 + import { 10 + type DeviceLocation, 11 + type GeolocationStatus, 12 + } from '#/state/geolocation/types' 13 + import {useSyncedDeviceGeolocation} from '#/state/geolocation/useSyncedDeviceGeolocation' 14 + import { 15 + computeGeolocationStatus, 16 + mergeGeolocation, 17 + } from '#/state/geolocation/util' 18 + import {type Device, device} from '#/storage' 19 + 20 + export * from '#/state/geolocation/config' 21 + export * from '#/state/geolocation/types' 22 + export * from '#/state/geolocation/util' 23 + 24 + type DeviceGeolocationContext = { 25 + deviceGeolocation: DeviceLocation | undefined 26 + } 27 + 28 + type DeviceGeolocationAPIContext = { 29 + setDeviceGeolocation(deviceGeolocation: DeviceLocation): void 30 + } 31 + 32 + type GeolocationConfigContext = { 33 + config: Device['geolocation'] 34 + } 35 + 36 + type GeolocationStatusContext = { 37 + /** 38 + * Merged geolocation from config and device GPS (if available). 39 + */ 40 + location: DeviceLocation 41 + /** 42 + * Computed geolocation status based on the merged location and config. 43 + */ 44 + status: GeolocationStatus 45 + } 46 + 47 + const DeviceGeolocationContext = React.createContext<DeviceGeolocationContext>({ 48 + deviceGeolocation: undefined, 49 + }) 50 + DeviceGeolocationContext.displayName = 'DeviceGeolocationContext' 51 + 52 + const DeviceGeolocationAPIContext = 53 + React.createContext<DeviceGeolocationAPIContext>({ 54 + setDeviceGeolocation: () => {}, 55 + }) 56 + DeviceGeolocationAPIContext.displayName = 'DeviceGeolocationAPIContext' 57 + 58 + const GeolocationConfigContext = React.createContext<GeolocationConfigContext>({ 59 + config: DEFAULT_GEOLOCATION_CONFIG, 60 + }) 61 + GeolocationConfigContext.displayName = 'GeolocationConfigContext' 62 + 63 + const GeolocationStatusContext = React.createContext<GeolocationStatusContext>({ 64 + location: { 65 + countryCode: undefined, 66 + regionCode: undefined, 67 + }, 68 + status: DEFAULT_GEOLOCATION_STATUS, 69 + }) 70 + GeolocationStatusContext.displayName = 'GeolocationStatusContext' 71 + 72 + /** 73 + * Provider of geolocation config and computed geolocation status. 74 + */ 75 + export function GeolocationStatusProvider({ 76 + children, 77 + }: { 78 + children: React.ReactNode 79 + }) { 80 + const {deviceGeolocation} = React.useContext(DeviceGeolocationContext) 81 + const [config, setConfig] = React.useState(() => { 82 + const initial = device.get(['geolocation']) || DEFAULT_GEOLOCATION_CONFIG 83 + return initial 84 + }) 85 + 86 + React.useEffect(() => { 87 + return onGeolocationConfigUpdate(config => { 88 + setConfig(config!) 89 + }) 90 + }, []) 91 + 92 + const configContext = React.useMemo(() => ({config}), [config]) 93 + const statusContext = React.useMemo(() => { 94 + if (deviceGeolocation) { 95 + logger.debug('geolocation: has device geolocation available') 96 + } 97 + const geolocation = mergeGeolocation(deviceGeolocation, config) 98 + const status = computeGeolocationStatus(geolocation, config) 99 + return {location: geolocation, status} 100 + }, [config, deviceGeolocation]) 101 + 102 + return ( 103 + <GeolocationConfigContext.Provider value={configContext}> 104 + <GeolocationStatusContext.Provider value={statusContext}> 105 + {children} 106 + </GeolocationStatusContext.Provider> 107 + </GeolocationConfigContext.Provider> 108 + ) 109 + } 110 + 111 + /** 112 + * Provider of providers. Provides device geolocation data to lower-level 113 + * `GeolocationStatusProvider`, and device geolocation APIs to children. 114 + */ 115 + export function Provider({children}: {children: React.ReactNode}) { 116 + const [deviceGeolocation, setDeviceGeolocation] = useSyncedDeviceGeolocation() 117 + 118 + const handleSetDeviceGeolocation = React.useCallback( 119 + (location: DeviceLocation) => { 120 + logger.debug('geolocation: setting device geolocation') 121 + setDeviceGeolocation({ 122 + countryCode: location.countryCode ?? undefined, 123 + regionCode: location.regionCode ?? undefined, 124 + }) 125 + }, 126 + [setDeviceGeolocation], 127 + ) 128 + 129 + return ( 130 + <DeviceGeolocationAPIContext.Provider 131 + value={React.useMemo( 132 + () => ({setDeviceGeolocation: handleSetDeviceGeolocation}), 133 + [handleSetDeviceGeolocation], 134 + )}> 135 + <DeviceGeolocationContext.Provider 136 + value={React.useMemo(() => ({deviceGeolocation}), [deviceGeolocation])}> 137 + <GeolocationStatusProvider>{children}</GeolocationStatusProvider> 138 + </DeviceGeolocationContext.Provider> 139 + </DeviceGeolocationAPIContext.Provider> 140 + ) 141 + } 142 + 143 + export function useDeviceGeolocationApi() { 144 + return React.useContext(DeviceGeolocationAPIContext) 145 + } 146 + 147 + export function useGeolocationConfig() { 148 + return React.useContext(GeolocationConfigContext) 149 + } 150 + 151 + export function useGeolocationStatus() { 152 + return React.useContext(GeolocationStatusContext) 153 + }
+3
src/state/geolocation/logger.ts
··· 1 + import {Logger} from '#/logger' 2 + 3 + export const logger = Logger.create(Logger.Context.Geolocation)
+9
src/state/geolocation/types.ts
··· 1 + export type DeviceLocation = { 2 + countryCode: string | undefined 3 + regionCode: string | undefined 4 + } 5 + 6 + export type GeolocationStatus = DeviceLocation & { 7 + isAgeRestrictedGeo: boolean 8 + isAgeBlockedGeo: boolean 9 + }
+43
src/state/geolocation/useRequestDeviceLocation.ts
··· 1 + import {useCallback} from 'react' 2 + import * as Location from 'expo-location' 3 + 4 + import {type DeviceLocation} from '#/state/geolocation/types' 5 + import {getDeviceGeolocation} from '#/state/geolocation/util' 6 + 7 + export {PermissionStatus} from 'expo-location' 8 + 9 + export function useRequestDeviceLocation(): () => Promise< 10 + | { 11 + granted: true 12 + location: DeviceLocation | undefined 13 + } 14 + | { 15 + granted: false 16 + status: { 17 + canAskAgain: boolean 18 + /** 19 + * Enum, use `PermissionStatus` export for comparisons 20 + */ 21 + permissionStatus: Location.PermissionStatus 22 + } 23 + } 24 + > { 25 + return useCallback(async () => { 26 + const status = await Location.requestForegroundPermissionsAsync() 27 + 28 + if (status.granted) { 29 + return { 30 + granted: true, 31 + location: await getDeviceGeolocation(), 32 + } 33 + } else { 34 + return { 35 + granted: false, 36 + status: { 37 + canAskAgain: status.canAskAgain, 38 + permissionStatus: status.status, 39 + }, 40 + } 41 + } 42 + }, []) 43 + }
+58
src/state/geolocation/useSyncedDeviceGeolocation.ts
··· 1 + import {useEffect, useRef} from 'react' 2 + import * as Location from 'expo-location' 3 + 4 + import {logger} from '#/state/geolocation/logger' 5 + import {getDeviceGeolocation} from '#/state/geolocation/util' 6 + import {device, useStorage} from '#/storage' 7 + 8 + /** 9 + * Hook to get and sync the device geolocation from the device GPS and store it 10 + * using device storage. If permissions are not granted, it will clear any cached 11 + * storage value. 12 + */ 13 + export function useSyncedDeviceGeolocation() { 14 + const synced = useRef(false) 15 + const [status] = Location.useForegroundPermissions() 16 + const [deviceGeolocation, setDeviceGeolocation] = useStorage(device, [ 17 + 'deviceGeolocation', 18 + ]) 19 + 20 + useEffect(() => { 21 + async function get() { 22 + // no need to set this more than once per session 23 + if (synced.current) return 24 + 25 + logger.debug('useSyncedDeviceGeolocation: checking perms') 26 + 27 + if (status?.granted) { 28 + const location = await getDeviceGeolocation() 29 + if (location) { 30 + logger.debug('useSyncedDeviceGeolocation: syncing location') 31 + setDeviceGeolocation(location) 32 + synced.current = true 33 + } 34 + } else { 35 + const hasCachedValue = device.get(['deviceGeolocation']) !== undefined 36 + 37 + /** 38 + * If we have a cached value, but user has revoked permissions, 39 + * quietly (will take effect lazily) clear this out. 40 + */ 41 + if (hasCachedValue) { 42 + logger.debug( 43 + 'useSyncedDeviceGeolocation: clearing cached location, perms revoked', 44 + ) 45 + device.set(['deviceGeolocation'], undefined) 46 + } 47 + } 48 + } 49 + 50 + get().catch(e => { 51 + logger.error('useSyncedDeviceGeolocation: failed to sync', { 52 + safeMessage: e, 53 + }) 54 + }) 55 + }, [status, setDeviceGeolocation]) 56 + 57 + return [deviceGeolocation, setDeviceGeolocation] as const 58 + }
+180
src/state/geolocation/util.ts
··· 1 + import { 2 + getCurrentPositionAsync, 3 + type LocationGeocodedAddress, 4 + reverseGeocodeAsync, 5 + } from 'expo-location' 6 + 7 + import {logger} from '#/state/geolocation/logger' 8 + import {type DeviceLocation} from '#/state/geolocation/types' 9 + import {type Device} from '#/storage' 10 + 11 + /** 12 + * Maps full US region names to their short codes. 13 + * 14 + * Context: in some cases, like on Android, we get the full region name instead 15 + * of the short code. We may need to expand this in the future to other 16 + * countries, hence the prefix. 17 + */ 18 + export const USRegionNameToRegionCode: { 19 + [regionName: string]: string 20 + } = { 21 + Alabama: 'AL', 22 + Alaska: 'AK', 23 + Arizona: 'AZ', 24 + Arkansas: 'AR', 25 + California: 'CA', 26 + Colorado: 'CO', 27 + Connecticut: 'CT', 28 + Delaware: 'DE', 29 + Florida: 'FL', 30 + Georgia: 'GA', 31 + Hawaii: 'HI', 32 + Idaho: 'ID', 33 + Illinois: 'IL', 34 + Indiana: 'IN', 35 + Iowa: 'IA', 36 + Kansas: 'KS', 37 + Kentucky: 'KY', 38 + Louisiana: 'LA', 39 + Maine: 'ME', 40 + Maryland: 'MD', 41 + Massachusetts: 'MA', 42 + Michigan: 'MI', 43 + Minnesota: 'MN', 44 + Mississippi: 'MS', 45 + Missouri: 'MO', 46 + Montana: 'MT', 47 + Nebraska: 'NE', 48 + Nevada: 'NV', 49 + ['New Hampshire']: 'NH', 50 + ['New Jersey']: 'NJ', 51 + ['New Mexico']: 'NM', 52 + ['New York']: 'NY', 53 + ['North Carolina']: 'NC', 54 + ['North Dakota']: 'ND', 55 + Ohio: 'OH', 56 + Oklahoma: 'OK', 57 + Oregon: 'OR', 58 + Pennsylvania: 'PA', 59 + ['Rhode Island']: 'RI', 60 + ['South Carolina']: 'SC', 61 + ['South Dakota']: 'SD', 62 + Tennessee: 'TN', 63 + Texas: 'TX', 64 + Utah: 'UT', 65 + Vermont: 'VT', 66 + Virginia: 'VA', 67 + Washington: 'WA', 68 + ['West Virginia']: 'WV', 69 + Wisconsin: 'WI', 70 + Wyoming: 'WY', 71 + } 72 + 73 + /** 74 + * Normalizes a `LocationGeocodedAddress` into a `DeviceLocation`. 75 + * 76 + * We don't want or care about the full location data, so we trim it down and 77 + * normalize certain fields, like region, into the format we need. 78 + */ 79 + export function normalizeDeviceLocation( 80 + location: LocationGeocodedAddress, 81 + ): DeviceLocation { 82 + let {isoCountryCode, region} = location 83 + 84 + if (region) { 85 + if (isoCountryCode === 'US') { 86 + region = USRegionNameToRegionCode[region] ?? region 87 + } 88 + } 89 + 90 + return { 91 + countryCode: isoCountryCode ?? undefined, 92 + regionCode: region ?? undefined, 93 + } 94 + } 95 + 96 + /** 97 + * Combines precise location data with the geolocation config fetched from the 98 + * IP service, with preference to the precise data. 99 + */ 100 + export function mergeGeolocation( 101 + location?: DeviceLocation, 102 + config?: Device['geolocation'], 103 + ): DeviceLocation { 104 + if (location?.countryCode) return location 105 + return { 106 + countryCode: config?.countryCode, 107 + regionCode: config?.regionCode, 108 + } 109 + } 110 + 111 + /** 112 + * Computes the geolocation status (age-restricted, age-blocked) based on the 113 + * given location and geolocation config. `location` here should be merged with 114 + * `mergeGeolocation()` ahead of time if needed. 115 + */ 116 + export function computeGeolocationStatus( 117 + location: DeviceLocation, 118 + config: Device['geolocation'], 119 + ) { 120 + /** 121 + * We can't do anything if we don't have this data. 122 + */ 123 + if (!location.countryCode) { 124 + return { 125 + ...location, 126 + isAgeRestrictedGeo: false, 127 + isAgeBlockedGeo: false, 128 + } 129 + } 130 + 131 + const isAgeRestrictedGeo = config?.ageRestrictedGeos?.some(rule => { 132 + if (rule.countryCode === location.countryCode) { 133 + if (!rule.regionCode) { 134 + return true // whole country is blocked 135 + } else if (rule.regionCode === location.regionCode) { 136 + return true 137 + } 138 + } 139 + }) 140 + 141 + const isAgeBlockedGeo = config?.ageBlockedGeos?.some(rule => { 142 + if (rule.countryCode === location.countryCode) { 143 + if (!rule.regionCode) { 144 + return true // whole country is blocked 145 + } else if (rule.regionCode === location.regionCode) { 146 + return true 147 + } 148 + } 149 + }) 150 + 151 + return { 152 + ...location, 153 + isAgeRestrictedGeo: !!isAgeRestrictedGeo, 154 + isAgeBlockedGeo: !!isAgeBlockedGeo, 155 + } 156 + } 157 + 158 + export async function getDeviceGeolocation(): Promise<DeviceLocation> { 159 + try { 160 + const geocode = await getCurrentPositionAsync() 161 + const locations = await reverseGeocodeAsync({ 162 + latitude: geocode.coords.latitude, 163 + longitude: geocode.coords.longitude, 164 + }) 165 + const location = locations.at(0) 166 + const normalized = location ? normalizeDeviceLocation(location) : undefined 167 + return { 168 + countryCode: normalized?.countryCode ?? undefined, 169 + regionCode: normalized?.regionCode ?? undefined, 170 + } 171 + } catch (e) { 172 + logger.error('getDeviceGeolocation: failed', { 173 + safeMessage: e, 174 + }) 175 + return { 176 + countryCode: undefined, 177 + regionCode: undefined, 178 + } 179 + } 180 + }
+65
src/state/queries/bookmarks/useBookmarkMutation.ts
··· 1 + import {type AppBskyFeedDefs} from '@atproto/api' 2 + import {useMutation, useQueryClient} from '@tanstack/react-query' 3 + 4 + import {isNetworkError} from '#/lib/strings/errors' 5 + import {logger} from '#/logger' 6 + import {updatePostShadow} from '#/state/cache/post-shadow' 7 + import { 8 + optimisticallyDeleteBookmark, 9 + optimisticallySaveBookmark, 10 + } from '#/state/queries/bookmarks/useBookmarksQuery' 11 + import {useAgent} from '#/state/session' 12 + 13 + type MutationArgs = 14 + | {action: 'create'; post: AppBskyFeedDefs.PostView} 15 + | { 16 + action: 'delete' 17 + /** 18 + * For deletions, we only need to URI. Plus, in some cases we only know the 19 + * URI, such as when a post was deleted by the author. 20 + */ 21 + uri: string 22 + } 23 + 24 + export function useBookmarkMutation() { 25 + const qc = useQueryClient() 26 + const agent = useAgent() 27 + 28 + return useMutation({ 29 + async mutationFn(args: MutationArgs) { 30 + if (args.action === 'create') { 31 + updatePostShadow(qc, args.post.uri, {bookmarked: true}) 32 + await agent.app.bsky.bookmark.createBookmark({ 33 + uri: args.post.uri, 34 + cid: args.post.cid, 35 + }) 36 + } else if (args.action === 'delete') { 37 + updatePostShadow(qc, args.uri, {bookmarked: false}) 38 + await agent.app.bsky.bookmark.deleteBookmark({ 39 + uri: args.uri, 40 + }) 41 + } 42 + }, 43 + onSuccess(_, args) { 44 + if (args.action === 'create') { 45 + optimisticallySaveBookmark(qc, args.post) 46 + } else if (args.action === 'delete') { 47 + optimisticallyDeleteBookmark(qc, {uri: args.uri}) 48 + } 49 + }, 50 + onError(e, args) { 51 + if (args.action === 'create') { 52 + updatePostShadow(qc, args.post.uri, {bookmarked: false}) 53 + } else if (args.action === 'delete') { 54 + updatePostShadow(qc, args.uri, {bookmarked: true}) 55 + } 56 + 57 + if (!isNetworkError(e)) { 58 + logger.error('bookmark mutation failed', { 59 + bookmarkAction: args.action, 60 + safeMessage: e, 61 + }) 62 + } 63 + }, 64 + }) 65 + }
+114
src/state/queries/bookmarks/useBookmarksQuery.ts
··· 1 + import { 2 + type $Typed, 3 + type AppBskyBookmarkGetBookmarks, 4 + type AppBskyFeedDefs, 5 + } from '@atproto/api' 6 + import { 7 + type InfiniteData, 8 + type QueryClient, 9 + type QueryKey, 10 + useInfiniteQuery, 11 + } from '@tanstack/react-query' 12 + 13 + import {useAgent} from '#/state/session' 14 + 15 + export const bookmarksQueryKeyRoot = 'bookmarks' 16 + export const createBookmarksQueryKey = () => [bookmarksQueryKeyRoot] 17 + 18 + export function useBookmarksQuery() { 19 + const agent = useAgent() 20 + 21 + return useInfiniteQuery< 22 + AppBskyBookmarkGetBookmarks.OutputSchema, 23 + Error, 24 + InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>, 25 + QueryKey, 26 + string | undefined 27 + >({ 28 + queryKey: createBookmarksQueryKey(), 29 + async queryFn({pageParam}) { 30 + const res = await agent.app.bsky.bookmark.getBookmarks({ 31 + cursor: pageParam, 32 + }) 33 + return res.data 34 + }, 35 + initialPageParam: undefined, 36 + getNextPageParam: lastPage => lastPage.cursor, 37 + }) 38 + } 39 + 40 + export async function truncateAndInvalidate(qc: QueryClient) { 41 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 42 + {queryKey: [bookmarksQueryKeyRoot]}, 43 + data => { 44 + if (data) { 45 + return { 46 + pageParams: data.pageParams.slice(0, 1), 47 + pages: data.pages.slice(0, 1), 48 + } 49 + } 50 + return data 51 + }, 52 + ) 53 + return qc.invalidateQueries({queryKey: [bookmarksQueryKeyRoot]}) 54 + } 55 + 56 + export async function optimisticallySaveBookmark( 57 + qc: QueryClient, 58 + post: AppBskyFeedDefs.PostView, 59 + ) { 60 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 61 + { 62 + queryKey: [bookmarksQueryKeyRoot], 63 + }, 64 + data => { 65 + if (!data) return data 66 + return { 67 + ...data, 68 + pages: data.pages.map((page, index) => { 69 + if (index === 0) { 70 + post.$type = 'app.bsky.feed.defs#postView' 71 + return { 72 + ...page, 73 + bookmarks: [ 74 + { 75 + createdAt: new Date().toISOString(), 76 + subject: { 77 + uri: post.uri, 78 + cid: post.cid, 79 + }, 80 + item: post as $Typed<AppBskyFeedDefs.PostView>, 81 + }, 82 + ...page.bookmarks, 83 + ], 84 + } 85 + } 86 + return page 87 + }), 88 + } 89 + }, 90 + ) 91 + } 92 + 93 + export async function optimisticallyDeleteBookmark( 94 + qc: QueryClient, 95 + {uri}: {uri: string}, 96 + ) { 97 + qc.setQueriesData<InfiniteData<AppBskyBookmarkGetBookmarks.OutputSchema>>( 98 + { 99 + queryKey: [bookmarksQueryKeyRoot], 100 + }, 101 + data => { 102 + if (!data) return data 103 + return { 104 + ...data, 105 + pages: data.pages.map(page => { 106 + return { 107 + ...page, 108 + bookmarks: page.bookmarks.filter(b => b.subject.uri !== uri), 109 + } 110 + }), 111 + } 112 + }, 113 + ) 114 + }
+6
src/state/queries/nuxs/definitions.ts
··· 9 9 ActivitySubscriptions = 'ActivitySubscriptions', 10 10 AgeAssuranceDismissibleNotice = 'AgeAssuranceDismissibleNotice', 11 11 AgeAssuranceDismissibleFeedBanner = 'AgeAssuranceDismissibleFeedBanner', 12 + BookmarksAnnouncement = 'BookmarksAnnouncement', 12 13 13 14 /* 14 15 * Blocking announcements. New IDs are required for each new announcement. ··· 47 48 id: Nux.PolicyUpdate202508 48 49 data: undefined 49 50 } 51 + | { 52 + id: Nux.BookmarksAnnouncement 53 + data: undefined 54 + } 50 55 > 51 56 52 57 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { ··· 57 62 [Nux.AgeAssuranceDismissibleNotice]: undefined, 58 63 [Nux.AgeAssuranceDismissibleFeedBanner]: undefined, 59 64 [Nux.PolicyUpdate202508]: undefined, 65 + [Nux.BookmarksAnnouncement]: undefined, 60 66 }
+23 -2
src/storage/schema.ts
··· 7 7 fontScale: '-2' | '-1' | '0' | '1' | '2' 8 8 fontFamily: 'system' | 'theme' 9 9 lastNuxDialog: string | undefined 10 + 11 + /** 12 + * Geolocation config, fetched from the IP service. This previously did 13 + * double duty as the "status" for geolocation state, but that has since 14 + * moved here to the client. 15 + */ 10 16 geolocation?: { 11 17 countryCode: string | undefined 12 - isAgeRestrictedGeo: boolean | undefined 13 - isAgeBlockedGeo: boolean | undefined 18 + regionCode: string | undefined 19 + ageRestrictedGeos: { 20 + countryCode: string 21 + regionCode: string | undefined 22 + }[] 23 + ageBlockedGeos: { 24 + countryCode: string 25 + regionCode: string | undefined 26 + }[] 14 27 } 28 + /** 29 + * The GPS-based geolocation, if the user has granted permission. 30 + */ 31 + deviceGeolocation?: { 32 + countryCode: string | undefined 33 + regionCode: string | undefined 34 + } 35 + 15 36 trendingBetaEnabled: boolean 16 37 devMode: boolean 17 38 demoMode: boolean
+7 -1
src/view/com/post/Post.tsx
··· 43 43 showReplyLine, 44 44 hideTopBorder, 45 45 style, 46 + onBeforePress, 46 47 }: { 47 48 post: AppBskyFeedDefs.PostView 48 49 showReplyLine?: boolean 49 50 hideTopBorder?: boolean 50 51 style?: StyleProp<ViewStyle> 52 + onBeforePress?: () => void 51 53 }) { 52 54 const moderationOpts = useModerationOpts() 53 55 const record = useMemo<AppBskyFeedPost.Record | undefined>( ··· 85 87 showReplyLine={showReplyLine} 86 88 hideTopBorder={hideTopBorder} 87 89 style={style} 90 + onBeforePress={onBeforePress} 88 91 /> 89 92 ) 90 93 } ··· 99 102 showReplyLine, 100 103 hideTopBorder, 101 104 style, 105 + onBeforePress: outerOnBeforePress, 102 106 }: { 103 107 post: Shadow<AppBskyFeedDefs.PostView> 104 108 record: AppBskyFeedPost.Record ··· 107 111 showReplyLine?: boolean 108 112 hideTopBorder?: boolean 109 113 style?: StyleProp<ViewStyle> 114 + onBeforePress?: () => void 110 115 }) { 111 116 const queryClient = useQueryClient() 112 117 const pal = usePalette('default') ··· 142 147 143 148 const onBeforePress = useCallback(() => { 144 149 unstableCacheProfileView(queryClient, post.author) 145 - }, [queryClient, post.author]) 150 + outerOnBeforePress?.() 151 + }, [queryClient, post.author, outerOnBeforePress]) 146 152 147 153 const [hover, setHover] = useState(false) 148 154 return (
+37
src/view/shell/Drawer.tsx
··· 30 30 Bell_Filled_Corner0_Rounded as BellFilled, 31 31 Bell_Stroke2_Corner0_Rounded as Bell, 32 32 } from '#/components/icons/Bell' 33 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 33 34 import {BulletList_Stroke2_Corner0_Rounded as List} from '#/components/icons/BulletList' 34 35 import { 35 36 Hashtag_Filled_Corner0_Rounded as HashtagFilled, ··· 150 151 isAtHome, 151 152 isAtSearch, 152 153 isAtFeeds, 154 + isAtBookmarks, 153 155 isAtNotifications, 154 156 isAtMyProfile, 155 157 isAtMessages, ··· 231 233 setDrawerOpen(false) 232 234 }, [navigation, setDrawerOpen]) 233 235 236 + const onPressBookmarks = React.useCallback(() => { 237 + navigation.navigate('Bookmarks') 238 + setDrawerOpen(false) 239 + }, [navigation, setDrawerOpen]) 240 + 234 241 const onPressSettings = React.useCallback(() => { 235 242 navigation.navigate('Settings') 236 243 setDrawerOpen(false) ··· 292 299 /> 293 300 <FeedsMenuItem isActive={isAtFeeds} onPress={onPressMyFeeds} /> 294 301 <ListsMenuItem onPress={onPressLists} /> 302 + <BookmarksMenuItem 303 + isActive={isAtBookmarks} 304 + onPress={onPressBookmarks} 305 + /> 295 306 <ProfileMenuItem 296 307 isActive={isAtMyProfile} 297 308 onPress={onPressProfile} ··· 537 548 ) 538 549 } 539 550 ListsMenuItem = React.memo(ListsMenuItem) 551 + 552 + let BookmarksMenuItem = ({ 553 + isActive, 554 + onPress, 555 + }: { 556 + isActive: boolean 557 + onPress: () => void 558 + }): React.ReactNode => { 559 + const {_} = useLingui() 560 + const t = useTheme() 561 + 562 + return ( 563 + <MenuItem 564 + icon={ 565 + isActive ? ( 566 + <BookmarkFilled style={[t.atoms.text]} width={iconWidth} /> 567 + ) : ( 568 + <Bookmark style={[t.atoms.text]} width={iconWidth} /> 569 + ) 570 + } 571 + label={_(msg`Saved`)} 572 + onPress={onPress} 573 + /> 574 + ) 575 + } 576 + BookmarksMenuItem = React.memo(BookmarksMenuItem) 540 577 541 578 let ProfileMenuItem = ({ 542 579 isActive,
+19
src/view/shell/desktop/LeftNav.tsx
··· 40 40 Bell_Filled_Corner0_Rounded as BellFilled, 41 41 Bell_Stroke2_Corner0_Rounded as Bell, 42 42 } from '#/components/icons/Bell' 43 + import {Bookmark, BookmarkFilled} from '#/components/icons/Bookmark' 43 44 import { 44 45 BulletList_Filled_Corner0_Rounded as ListFilled, 45 46 BulletList_Stroke2_Corner0_Rounded as List, ··· 744 745 /> 745 746 } 746 747 label={_(msg`Lists`)} 748 + /> 749 + <NavItem 750 + href="/saved" 751 + icon={ 752 + <Bookmark 753 + style={pal.text} 754 + aria-hidden={true} 755 + width={NAV_ICON_WIDTH} 756 + /> 757 + } 758 + iconFilled={ 759 + <BookmarkFilled 760 + style={pal.text} 761 + aria-hidden={true} 762 + width={NAV_ICON_WIDTH} 763 + /> 764 + } 765 + label={_(msg`Saved`)} 747 766 /> 748 767 <NavItem 749 768 href={currentAccount ? makeProfileLink(currentAccount) : '/'}
+2 -2
src/view/shell/index.tsx
··· 13 13 import {isStateAtTabRoot} from '#/lib/routes/helpers' 14 14 import {isAndroid, isIOS} from '#/platform/detection' 15 15 import {useDialogFullyExpandedCountContext} from '#/state/dialogs' 16 - import {useGeolocation} from '#/state/geolocation' 16 + import {useGeolocationStatus} from '#/state/geolocation' 17 17 import {useSession} from '#/state/session' 18 18 import { 19 19 useIsDrawerOpen, ··· 184 184 185 185 export function Shell() { 186 186 const t = useTheme() 187 - const {geolocation} = useGeolocation() 187 + const {status: geolocation} = useGeolocationStatus() 188 188 const fullyExpandedCount = useDialogFullyExpandedCountContext() 189 189 190 190 useIntentHandler()
+12 -2
src/view/shell/index.web.tsx
··· 8 8 import {useIntentHandler} from '#/lib/hooks/useIntentHandler' 9 9 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' 10 10 import {type NavigationProp} from '#/lib/routes/types' 11 - import {useGeolocation} from '#/state/geolocation' 11 + import {useGate} from '#/lib/statsig/statsig' 12 + import {useGeolocationStatus} from '#/state/geolocation' 12 13 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' 13 14 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' 14 15 import {useCloseAllActiveElements} from '#/state/util' ··· 22 23 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' 23 24 import {MutedWordsDialog} from '#/components/dialogs/MutedWords' 24 25 import {SigninDialog} from '#/components/dialogs/Signin' 26 + import {useWelcomeModal} from '#/components/hooks/useWelcomeModal' 25 27 import { 26 28 Outlet as PolicyUpdateOverlayPortalOutlet, 27 29 usePolicyUpdateContext, 28 30 } from '#/components/PolicyUpdateOverlay' 29 31 import {Outlet as PortalOutlet} from '#/components/Portal' 32 + import {WelcomeModal} from '#/components/WelcomeModal' 30 33 import {FlatNavigator, RoutesContainer} from '#/Navigation' 31 34 import {Composer} from './Composer.web' 32 35 import {DrawerContent} from './Drawer' ··· 42 45 const showDrawer = !isDesktop && isDrawerOpen 43 46 const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer) 44 47 const {state: policyUpdateState} = usePolicyUpdateContext() 48 + const welcomeModalControl = useWelcomeModal() 49 + const gate = useGate() 45 50 46 51 useLayoutEffect(() => { 47 52 if (showDrawer !== showDrawerDelayedExit) { ··· 80 85 <LinkWarningDialog /> 81 86 <Lightbox /> 82 87 88 + {/* Show welcome modal if the gate is enabled */} 89 + {welcomeModalControl.isOpen && gate('welcome_modal') && ( 90 + <WelcomeModal control={welcomeModalControl} /> 91 + )} 92 + 83 93 {/* Until policy update has been completed by the user, don't render anything that is portaled */} 84 94 {policyUpdateState.completed && ( 85 95 <> ··· 132 142 133 143 export function Shell() { 134 144 const t = useTheme() 135 - const {geolocation} = useGeolocation() 145 + const {status: geolocation} = useGeolocationStatus() 136 146 return ( 137 147 <View style={[a.util_screen_outer, t.atoms.bg]}> 138 148 {geolocation?.isAgeBlockedGeo ? (
+40 -30
yarn.lock
··· 63 63 "@atproto/xrpc" "^0.7.3" 64 64 "@atproto/xrpc-server" "^0.9.3" 65 65 66 - "@atproto/api@^0.16.2": 67 - version "0.16.2" 68 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.2.tgz#1b2870e9a03d88f00a27602281755fa82ec824dd" 69 - integrity sha512-sSTg31J8ws8DNaoiizp+/uJideRxRaJsq+Nyl8rnSxGw0w3oCvoeRU19iRWh2t0jZEmiRJAGkveGu23NKmPYEQ== 66 + "@atproto/api@^0.16.4": 67 + version "0.16.4" 68 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.4.tgz#952071aca39a731b1664dc3ea4385fa2fb8e4c62" 69 + integrity sha512-beAOh0C7uH2F3/BUDUV6lHvxuwRPp+afIneWA9+8iDgkNV2JFuIm769FcjYQ0slXyJ21PxI0IDfOs6Jqtu72Xw== 70 70 dependencies: 71 71 "@atproto/common-web" "^0.4.2" 72 - "@atproto/lexicon" "^0.4.12" 72 + "@atproto/lexicon" "^0.4.14" 73 73 "@atproto/syntax" "^0.4.0" 74 - "@atproto/xrpc" "^0.7.1" 74 + "@atproto/xrpc" "^0.7.3" 75 75 await-lock "^2.2.2" 76 76 multiformats "^9.9.0" 77 77 tlds "^1.234.0" 78 78 zod "^3.23.8" 79 79 80 - "@atproto/api@^0.16.4": 81 - version "0.16.4" 82 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.4.tgz#952071aca39a731b1664dc3ea4385fa2fb8e4c62" 83 - integrity sha512-beAOh0C7uH2F3/BUDUV6lHvxuwRPp+afIneWA9+8iDgkNV2JFuIm769FcjYQ0slXyJ21PxI0IDfOs6Jqtu72Xw== 80 + "@atproto/api@^0.16.7": 81 + version "0.16.7" 82 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.16.7.tgz#eb0c520dbdaf74ba6f5ad7f9c6afe2d1389b8a0a" 83 + integrity sha512-EdVWkEgaEQm1LEiiP1fW/XXXpMNmtvT5c9+cZVRiwYc4rTB66WIJJWqmaMT/tB7nccMkFjr6FtwObq5LewWfgw== 84 84 dependencies: 85 85 "@atproto/common-web" "^0.4.2" 86 - "@atproto/lexicon" "^0.4.14" 87 - "@atproto/syntax" "^0.4.0" 88 - "@atproto/xrpc" "^0.7.3" 86 + "@atproto/lexicon" "^0.5.0" 87 + "@atproto/syntax" "^0.4.1" 88 + "@atproto/xrpc" "^0.7.4" 89 89 await-lock "^2.2.2" 90 90 multiformats "^9.9.0" 91 91 tlds "^1.234.0" ··· 293 293 multiformats "^9.9.0" 294 294 zod "^3.23.8" 295 295 296 - "@atproto/lexicon@^0.4.12": 297 - version "0.4.12" 298 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.12.tgz#89a704789d983f8405a52095769b5b58d87f5af7" 299 - integrity sha512-fcEvEQ1GpQYF5igZ4IZjPWEoWVpsEF22L9RexxLS3ptfySXLflEyH384e7HITzO/73McDeaJx3lqHIuqn9ulnw== 296 + "@atproto/lexicon@^0.4.14": 297 + version "0.4.14" 298 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.14.tgz#a2b5f2bb950d41e78d18f276a01d71b5d89183d8" 299 + integrity sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ== 300 300 dependencies: 301 301 "@atproto/common-web" "^0.4.2" 302 302 "@atproto/syntax" "^0.4.0" ··· 304 304 multiformats "^9.9.0" 305 305 zod "^3.23.8" 306 306 307 - "@atproto/lexicon@^0.4.14": 308 - version "0.4.14" 309 - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.14.tgz#a2b5f2bb950d41e78d18f276a01d71b5d89183d8" 310 - integrity sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ== 307 + "@atproto/lexicon@^0.5.0": 308 + version "0.5.0" 309 + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.5.0.tgz#4d2be425361f9ac7f9754b8a1ccba29ddf0b9460" 310 + integrity sha512-3aAzEAy9EAPs3CxznzMhEcqDd7m3vz1eze/ya9/ThbB7yleqJIhz5GY2q76tCCwHPhn5qDDMhlA9kKV6fG23gA== 311 311 dependencies: 312 312 "@atproto/common-web" "^0.4.2" 313 - "@atproto/syntax" "^0.4.0" 313 + "@atproto/syntax" "^0.4.1" 314 314 iso-datestring-validator "^2.2.2" 315 315 multiformats "^9.9.0" 316 316 zod "^3.23.8" ··· 495 495 resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.0.tgz#bec71552087bb24c208a06ef418c0040b65542f2" 496 496 integrity sha512-b9y5ceHS8YKOfP3mdKmwAx5yVj9294UN7FG2XzP6V5aKUdFazEYRnR9m5n5ZQFKa3GNvz7de9guZCJ/sUTcOAA== 497 497 498 + "@atproto/syntax@^0.4.1": 499 + version "0.4.1" 500 + resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.4.1.tgz#f77bc610ae0914449ff3f4731861e3da429915f5" 501 + integrity sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw== 502 + 498 503 "@atproto/xrpc-server@^0.9.3": 499 504 version "0.9.3" 500 505 resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.9.3.tgz#45877ca9432c61294b8b7b1ba7a2430add327f82" ··· 513 518 ws "^8.12.0" 514 519 zod "^3.23.8" 515 520 516 - "@atproto/xrpc@^0.7.1": 517 - version "0.7.1" 518 - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.1.tgz#51a8fc131eb21bd1229129d0a46384accc50ad65" 519 - integrity sha512-ANHEzlskYlMEdH18m+Itp3a8d0pEJao2qoDybDoMupTnoeNkya4VKIaOgAi6ERQnqatBBZyn9asW+7rJmSt/8g== 520 - dependencies: 521 - "@atproto/lexicon" "^0.4.12" 522 - zod "^3.23.8" 523 - 524 521 "@atproto/xrpc@^0.7.3": 525 522 version "0.7.3" 526 523 resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.3.tgz#e93692326b765426e1e2cca811a668fb7d67303c" 527 524 integrity sha512-JaJbZ4ymIJzOakR3B/B+6NyppW3oQWn06OtQq03LqVsu93Afpc8VkNtPN3QnhQcD/yYSYCu73lLsDM/ErJEk7Q== 528 525 dependencies: 529 526 "@atproto/lexicon" "^0.4.14" 527 + zod "^3.23.8" 528 + 529 + "@atproto/xrpc@^0.7.4": 530 + version "0.7.4" 531 + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.7.4.tgz#030342548797c1f344968c457a8659dbb60a2d60" 532 + integrity sha512-sDi68+QE1XHegTaNAndlX41Gp827pouSzSs8CyAwhrqZdsJUxE3P7TMtrA0z+zAjvxVyvzscRc0TsN/fGUGrhw== 533 + dependencies: 534 + "@atproto/lexicon" "^0.5.0" 530 535 zod "^3.23.8" 531 536 532 537 "@aws-crypto/crc32@3.0.0": ··· 11385 11390 integrity sha512-dymvf0S11afyMeRbnoXd2iWWzFYwg21jHTnLBO/7ObNO1rKlYpus0ghVDnh+sJFV2u7s518e/JTcAqNR69EZkw== 11386 11391 dependencies: 11387 11392 rtl-detect "^1.0.2" 11393 + 11394 + expo-location@~18.1.6: 11395 + version "18.1.6" 11396 + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-18.1.6.tgz#b855e14e8b4e29a1bde470fc4dc2a341abecf631" 11397 + integrity sha512-l5dQQ2FYkrBgNzaZN1BvSmdhhcztFOUucu2kEfDBMV4wSIuTIt/CKsho+F3RnAiWgvui1wb1WTTf80E8zq48hA== 11388 11398 11389 11399 expo-manifests@~0.16.5: 11390 11400 version "0.16.5"