+6
.env.example
+6
.env.example
+1
app.config.js
+1
app.config.js
+1
assets/icons/bookmark.svg
+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
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
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
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
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
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
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
assets/images/bookmarks_announcement_nux.webp
This is a binary file and will not be displayed.
assets/images/welcome-modal-bg.jpg
assets/images/welcome-modal-bg.jpg
This is a binary file and will not be displayed.
+31
bskylink/src/db/migrations/003-safelink-cursor-constraint.ts
+31
bskylink/src/db/migrations/003-safelink-cursor-constraint.ts
···
1
+
import {type Kysely} from 'kysely'
2
+
3
+
export async function up(
4
+
db: Kysely<{safelink_rule: {}; safelink_cursor: {}}>,
5
+
): Promise<void> {
6
+
// Remove existing items from safelink_rule that were duplicated due to broken cursor
7
+
await db.deleteFrom(['safelink_rule']).execute()
8
+
9
+
// Delete the old cursor
10
+
await db.deleteFrom(['safelink_cursor']).execute()
11
+
12
+
await db.schema
13
+
.alterTable('safelink_cursor')
14
+
.addPrimaryKeyConstraint('pk_id', ['id'])
15
+
.execute()
16
+
}
17
+
18
+
export async function down(
19
+
db: Kysely<{safelink_rule: {}; safelink_cursor: {}}>,
20
+
): Promise<void> {
21
+
// Remove any rules that were added
22
+
await db.deleteFrom(['safelink_rule']).execute()
23
+
24
+
// Delete the cursor
25
+
await db.deleteFrom(['safelink_cursor']).execute()
26
+
27
+
await db.schema
28
+
.alterTable('safelink_cursor')
29
+
.dropConstraint('pk_id')
30
+
.execute()
31
+
}
+2
bskylink/src/db/migrations/index.ts
+2
bskylink/src/db/migrations/index.ts
+3
bskyweb/cmd/bskyweb/server.go
+3
bskyweb/cmd/bskyweb/server.go
+3
-2
package.json
+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
+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
+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) {
+1
src/alf/atoms.ts
+1
src/alf/atoms.ts
+130
-45
src/components/BlockedGeoOverlay.tsx
+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
+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
+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
+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
-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>
+125
-96
src/components/PostControls/index.tsx
+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
+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
+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
+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
+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
+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
+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
+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
+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
+3
src/components/hooks/useWelcomeModal.native.ts
+43
src/components/hooks/useWelcomeModal.ts
+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
+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
+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
+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
+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
-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
+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
+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
+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) {
+3
src/lib/hooks/useWebMediaQueries.tsx
+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
+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
+1
src/lib/routes/types.ts
+1
src/lib/statsig/gates.ts
+1
src/lib/statsig/gates.ts
+387
-218
src/locale/locales/en/messages.po
+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
+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
+1
src/logger/types.ts
+1
src/routes.ts
+1
src/routes.ts
+59
src/screens/Bookmarks/components/EmptyState.tsx
+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
+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
+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
+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
+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
+1
src/screens/PostThread/components/ThreadItemTreePost.tsx
+2
-2
src/state/ageAssurance/index.tsx
+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
+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
+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
+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
-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
+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
+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
+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
+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
+3
src/state/geolocation/logger.ts
+9
src/state/geolocation/types.ts
+9
src/state/geolocation/types.ts
+43
src/state/geolocation/useRequestDeviceLocation.ts
+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
+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
+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
+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
+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
+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
+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
+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
+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,
+2
-2
src/view/shell/index.tsx
+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
+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
+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"