+1
assets/icons/bellPlus_stroke2_corner0_rounded.svg
+1
assets/icons/bellPlus_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 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z"/></svg>
+1
assets/icons/bellRinging_filled_corner0_rounded.svg
+1
assets/icons/bellRinging_filled_corner0_rounded.svg
···
1
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.5 9.5 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.5 11.5 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.5 9.5 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z"/></svg>
assets/images/activity_notifications_announcement.webp
assets/images/activity_notifications_announcement.webp
This is a binary file and will not be displayed.
+2
bskyweb/cmd/bskyweb/server.go
+2
bskyweb/cmd/bskyweb/server.go
···
258
258
e.GET("/feeds", server.WebGeneric)
259
259
e.GET("/notifications", server.WebGeneric)
260
260
e.GET("/notifications/settings", server.WebGeneric)
261
+
e.GET("/notifications/activity", server.WebGeneric)
261
262
e.GET("/lists", server.WebGeneric)
262
263
e.GET("/moderation", server.WebGeneric)
263
264
e.GET("/moderation/modlists", server.WebGeneric)
···
275
276
e.GET("/settings/appearance", server.WebGeneric)
276
277
e.GET("/settings/account", server.WebGeneric)
277
278
e.GET("/settings/privacy-and-security", server.WebGeneric)
279
+
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
278
280
e.GET("/settings/content-and-media", server.WebGeneric)
279
281
e.GET("/settings/interests", server.WebGeneric)
280
282
e.GET("/settings/about", server.WebGeneric)
+2
-2
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
+2
-2
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
···
45
45
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
46
46
// If oreo or higher
47
47
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
48
-
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost"
48
+
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
49
49
// assign to it's eponymous channel. otherwise do nothing, let expo handle it
50
50
when (remoteMessage.data["reason"]) {
51
-
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> {
51
+
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
52
52
remoteMessage.data["channelId"] = remoteMessage.data["reason"]
53
53
}
54
54
}
+2
-2
package.json
+2
-2
package.json
···
69
69
"icons:optimize": "svgo -f ./assets/icons"
70
70
},
71
71
"dependencies": {
72
-
"@atproto/api": "^0.15.16",
72
+
"@atproto/api": "^0.15.21",
73
73
"@bitdrift/react-native": "^0.6.8",
74
74
"@braintree/sanitize-url": "^6.0.2",
75
75
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
···
218
218
"zod": "^3.20.2"
219
219
},
220
220
"devDependencies": {
221
-
"@atproto/dev-env": "^0.3.144",
221
+
"@atproto/dev-env": "^0.3.150",
222
222
"@babel/core": "^7.26.0",
223
223
"@babel/preset-env": "^7.26.0",
224
224
"@babel/runtime": "^7.26.0",
+69
-4
src/components/ProfileCard.tsx
+69
-4
src/components/ProfileCard.tsx
···
11
11
import {useActorStatus} from '#/lib/actor-status'
12
12
import {getModerationCauseKey} from '#/lib/moderation'
13
13
import {type LogEvents} from '#/lib/statsig/statsig'
14
+
import {forceLTR} from '#/lib/strings/bidi'
15
+
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
14
16
import {sanitizeDisplayName} from '#/lib/strings/display-names'
15
17
import {sanitizeHandle} from '#/lib/strings/handles'
16
18
import {useProfileShadow} from '#/state/cache/profile-shadow'
···
18
20
import {useSession} from '#/state/session'
19
21
import * as Toast from '#/view/com/util/Toast'
20
22
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
21
-
import {atoms as a, useTheme} from '#/alf'
23
+
import {atoms as a, platform, useTheme} from '#/alf'
22
24
import {
23
25
Button,
24
26
ButtonIcon,
···
183
185
export function NameAndHandle({
184
186
profile,
185
187
moderationOpts,
188
+
inline = false,
186
189
}: {
187
190
profile: bsky.profile.AnyProfileView
188
191
moderationOpts: ModerationOpts
192
+
inline?: boolean
189
193
}) {
194
+
if (inline) {
195
+
return (
196
+
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
197
+
)
198
+
} else {
199
+
return (
200
+
<View style={[a.flex_1]}>
201
+
<Name profile={profile} moderationOpts={moderationOpts} />
202
+
<Handle profile={profile} />
203
+
</View>
204
+
)
205
+
}
206
+
}
207
+
208
+
function InlineNameAndHandle({
209
+
profile,
210
+
moderationOpts,
211
+
}: {
212
+
profile: bsky.profile.AnyProfileView
213
+
moderationOpts: ModerationOpts
214
+
}) {
215
+
const t = useTheme()
216
+
const verification = useSimpleVerificationState({profile})
217
+
const moderation = moderateProfile(profile, moderationOpts)
218
+
const name = sanitizeDisplayName(
219
+
profile.displayName || sanitizeHandle(profile.handle),
220
+
moderation.ui('displayName'),
221
+
)
222
+
const handle = sanitizeHandle(profile.handle, '@')
190
223
return (
191
-
<View style={[a.flex_1]}>
192
-
<Name profile={profile} moderationOpts={moderationOpts} />
193
-
<Handle profile={profile} />
224
+
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
225
+
<Text
226
+
emoji
227
+
style={[
228
+
a.font_bold,
229
+
a.leading_tight,
230
+
a.flex_shrink_0,
231
+
{maxWidth: '70%'},
232
+
]}
233
+
numberOfLines={1}>
234
+
{forceLTR(name)}
235
+
</Text>
236
+
{verification.showBadge && (
237
+
<View
238
+
style={[
239
+
a.pl_2xs,
240
+
a.self_center,
241
+
{marginTop: platform({default: 0, android: -1})},
242
+
]}>
243
+
<VerificationCheck
244
+
width={platform({android: 13, default: 12})}
245
+
verifier={verification.role === 'verifier'}
246
+
/>
247
+
</View>
248
+
)}
249
+
<Text
250
+
emoji
251
+
style={[
252
+
a.leading_tight,
253
+
t.atoms.text_contrast_medium,
254
+
{flexShrink: 10},
255
+
]}
256
+
numberOfLines={1}>
257
+
{NON_BREAKING_SPACE + handle}
258
+
</Text>
194
259
</View>
195
260
)
196
261
}
+46
-48
src/components/Tooltip/index.tsx
+46
-48
src/components/Tooltip/index.tsx
···
3
3
createContext,
4
4
useCallback,
5
5
useContext,
6
+
useEffect,
6
7
useMemo,
7
8
useRef,
8
9
useState,
···
30
31
31
32
type TooltipContextType = {
32
33
position: 'top' | 'bottom'
33
-
ready: boolean
34
+
visible: boolean
34
35
onVisibleChange: (visible: boolean) => void
35
36
}
36
37
38
+
type TargetMeasurements = {
39
+
x: number
40
+
y: number
41
+
width: number
42
+
height: number
43
+
}
44
+
37
45
type TargetContextType = {
38
-
targetMeasurements:
39
-
| {
40
-
x: number
41
-
y: number
42
-
width: number
43
-
height: number
44
-
}
45
-
| undefined
46
-
targetRef: React.RefObject<View>
46
+
targetMeasurements: TargetMeasurements | undefined
47
+
setTargetMeasurements: (measurements: TargetMeasurements) => void
48
+
shouldMeasure: boolean
47
49
}
48
50
49
51
const TooltipContext = createContext<TooltipContextType>({
50
52
position: 'bottom',
51
-
ready: false,
53
+
visible: false,
52
54
onVisibleChange: () => {},
53
55
})
54
56
55
57
const TargetContext = createContext<TargetContextType>({
56
58
targetMeasurements: undefined,
57
-
targetRef: {current: null},
59
+
setTargetMeasurements: () => {},
60
+
shouldMeasure: false,
58
61
})
59
62
60
63
export function Outer({
···
69
72
onVisibleChange: (visible: boolean) => void
70
73
}) {
71
74
/**
72
-
* Whether we have measured the target and are ready to show the tooltip.
73
-
*/
74
-
const [ready, setReady] = useState(false)
75
-
/**
76
75
* Lagging state to track the externally-controlled visibility of the
77
-
* tooltip.
76
+
* tooltip, which needs to wait for the target to be measured before
77
+
* actually being shown.
78
78
*/
79
-
const [prevRequestVisible, setPrevRequestVisible] = useState<
80
-
boolean | undefined
81
-
>()
82
-
/**
83
-
* Needs to reference the element this Tooltip is attached to.
84
-
*/
85
-
const targetRef = useRef<View>(null)
79
+
const [visible, setVisible] = useState<boolean>(false)
86
80
const [targetMeasurements, setTargetMeasurements] = useState<
87
81
| {
88
82
x: number
···
93
87
| undefined
94
88
>(undefined)
95
89
96
-
if (requestVisible && !prevRequestVisible) {
97
-
setPrevRequestVisible(true)
98
-
99
-
if (targetRef.current) {
100
-
/*
101
-
* Once opened, measure the dimensions and position of the target
102
-
*/
103
-
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
104
-
if (pageX !== undefined && pageY !== undefined && width && height) {
105
-
setTargetMeasurements({x: pageX, y: pageY, width, height})
106
-
setReady(true)
107
-
}
108
-
})
109
-
}
110
-
} else if (!requestVisible && prevRequestVisible) {
111
-
setPrevRequestVisible(false)
90
+
if (requestVisible && !visible && targetMeasurements) {
91
+
setVisible(true)
92
+
} else if (!requestVisible && visible) {
93
+
setVisible(false)
112
94
setTargetMeasurements(undefined)
113
-
setReady(false)
114
95
}
115
96
116
97
const ctx = useMemo(
117
-
() => ({position, ready, onVisibleChange}),
118
-
[position, ready, onVisibleChange],
98
+
() => ({position, visible, onVisibleChange}),
99
+
[position, visible, onVisibleChange],
119
100
)
120
101
const targetCtx = useMemo(
121
-
() => ({targetMeasurements, targetRef}),
122
-
[targetMeasurements, targetRef],
102
+
() => ({
103
+
targetMeasurements,
104
+
setTargetMeasurements,
105
+
shouldMeasure: requestVisible,
106
+
}),
107
+
[requestVisible, targetMeasurements, setTargetMeasurements],
123
108
)
124
109
125
110
return (
···
132
117
}
133
118
134
119
export function Target({children}: {children: React.ReactNode}) {
135
-
const {targetRef} = useContext(TargetContext)
120
+
const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
121
+
const targetRef = useRef<View>(null)
122
+
123
+
useEffect(() => {
124
+
if (!shouldMeasure) return
125
+
/*
126
+
* Once opened, measure the dimensions and position of the target
127
+
*/
128
+
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
129
+
if (pageX !== undefined && pageY !== undefined && width && height) {
130
+
setTargetMeasurements({x: pageX, y: pageY, width, height})
131
+
}
132
+
})
133
+
}, [shouldMeasure, setTargetMeasurements])
136
134
137
135
return (
138
136
<View collapsable={false} ref={targetRef}>
···
148
146
children: React.ReactNode
149
147
label: string
150
148
}) {
151
-
const {position, ready, onVisibleChange} = useContext(TooltipContext)
149
+
const {position, visible, onVisibleChange} = useContext(TooltipContext)
152
150
const {targetMeasurements} = useContext(TargetContext)
153
151
const requestClose = useCallback(() => {
154
152
onVisibleChange(false)
155
153
}, [onVisibleChange])
156
154
157
-
if (!ready || !targetMeasurements) return null
155
+
if (!visible || !targetMeasurements) return null
158
156
159
157
return (
160
158
<Portal>
+8
-2
src/components/Tooltip/index.web.tsx
+8
-2
src/components/Tooltip/index.web.tsx
···
13
13
14
14
type TooltipContextType = {
15
15
position: 'top' | 'bottom'
16
+
onVisibleChange: (open: boolean) => void
16
17
}
17
18
18
19
const TooltipContext = createContext<TooltipContextType>({
19
20
position: 'bottom',
21
+
onVisibleChange: () => {},
20
22
})
21
23
22
24
export function Outer({
···
30
32
visible: boolean
31
33
onVisibleChange: (visible: boolean) => void
32
34
}) {
33
-
const ctx = useMemo(() => ({position}), [position])
35
+
const ctx = useMemo(
36
+
() => ({position, onVisibleChange}),
37
+
[position, onVisibleChange],
38
+
)
34
39
return (
35
40
<Popover.Root open={visible} onOpenChange={onVisibleChange}>
36
41
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
···
54
59
label: string
55
60
}) {
56
61
const t = useTheme()
57
-
const {position} = useContext(TooltipContext)
62
+
const {position, onVisibleChange} = useContext(TooltipContext)
58
63
return (
59
64
<Popover.Portal>
60
65
<Popover.Content
···
63
68
side={position}
64
69
sideOffset={4}
65
70
collisionPadding={MIN_EDGE_SPACE}
71
+
onInteractOutside={() => onVisibleChange(false)}
66
72
style={flatten([
67
73
a.rounded_sm,
68
74
select(t.name, {
+89
src/components/activity-notifications/SubscribeProfileButton.tsx
+89
src/components/activity-notifications/SubscribeProfileButton.tsx
···
1
+
import {useCallback} from 'react'
2
+
import {type ModerationOpts} from '@atproto/api'
3
+
import {msg, Trans} from '@lingui/macro'
4
+
import {useLingui} from '@lingui/react'
5
+
6
+
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
7
+
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
8
+
import {Button, ButtonIcon} from '#/components/Button'
9
+
import {useDialogControl} from '#/components/Dialog'
10
+
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
11
+
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
12
+
import * as Tooltip from '#/components/Tooltip'
13
+
import {Text} from '#/components/Typography'
14
+
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
15
+
import type * as bsky from '#/types/bsky'
16
+
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
17
+
18
+
export function SubscribeProfileButton({
19
+
profile,
20
+
moderationOpts,
21
+
}: {
22
+
profile: bsky.profile.AnyProfileView
23
+
moderationOpts: ModerationOpts
24
+
}) {
25
+
const {_} = useLingui()
26
+
const requireEmailVerification = useRequireEmailVerification()
27
+
const subscribeDialogControl = useDialogControl()
28
+
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
29
+
useActivitySubscriptionsNudged()
30
+
31
+
const onDismissTooltip = () => {
32
+
setActivitySubscriptionsNudged(true)
33
+
}
34
+
35
+
const onPress = useCallback(() => {
36
+
subscribeDialogControl.open()
37
+
}, [subscribeDialogControl])
38
+
39
+
const name = createSanitizedDisplayName(profile, true)
40
+
41
+
const wrappedOnPress = requireEmailVerification(onPress, {
42
+
instructions: [
43
+
<Trans key="message">
44
+
Before you can get notifications for {name}'s posts, you must first
45
+
verify your email.
46
+
</Trans>,
47
+
],
48
+
})
49
+
50
+
const isSubscribed =
51
+
profile.viewer?.activitySubscription?.post ||
52
+
profile.viewer?.activitySubscription?.reply
53
+
54
+
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
55
+
56
+
return (
57
+
<>
58
+
<Tooltip.Outer
59
+
visible={!activitySubscriptionsNudged}
60
+
onVisibleChange={onDismissTooltip}
61
+
position="bottom">
62
+
<Tooltip.Target>
63
+
<Button
64
+
accessibilityRole="button"
65
+
testID="dmBtn"
66
+
size="small"
67
+
color="secondary"
68
+
variant="solid"
69
+
shape="round"
70
+
label={_(msg`Get notified when ${name} posts`)}
71
+
onPress={wrappedOnPress}>
72
+
<ButtonIcon icon={Icon} size="md" />
73
+
</Button>
74
+
</Tooltip.Target>
75
+
<Tooltip.TextBubble>
76
+
<Text>
77
+
<Trans>Get notified about new posts</Trans>
78
+
</Text>
79
+
</Tooltip.TextBubble>
80
+
</Tooltip.Outer>
81
+
82
+
<SubscribeProfileDialog
83
+
control={subscribeDialogControl}
84
+
profile={profile}
85
+
moderationOpts={moderationOpts}
86
+
/>
87
+
</>
88
+
)
89
+
}
+309
src/components/activity-notifications/SubscribeProfileDialog.tsx
+309
src/components/activity-notifications/SubscribeProfileDialog.tsx
···
1
+
import {useMemo, useState} from 'react'
2
+
import {View} from 'react-native'
3
+
import {
4
+
type AppBskyNotificationDefs,
5
+
type AppBskyNotificationListActivitySubscriptions,
6
+
type ModerationOpts,
7
+
type Un$Typed,
8
+
} from '@atproto/api'
9
+
import {msg, Trans} from '@lingui/macro'
10
+
import {useLingui} from '@lingui/react'
11
+
import {
12
+
type InfiniteData,
13
+
useMutation,
14
+
useQueryClient,
15
+
} from '@tanstack/react-query'
16
+
17
+
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
18
+
import {cleanError} from '#/lib/strings/errors'
19
+
import {sanitizeHandle} from '#/lib/strings/handles'
20
+
import {logger} from '#/logger'
21
+
import {isWeb} from '#/platform/detection'
22
+
import {updateProfileShadow} from '#/state/cache/profile-shadow'
23
+
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
24
+
import {useAgent} from '#/state/session'
25
+
import * as Toast from '#/view/com/util/Toast'
26
+
import {platform, useTheme, web} from '#/alf'
27
+
import {atoms as a} from '#/alf'
28
+
import {Admonition} from '#/components/Admonition'
29
+
import {
30
+
Button,
31
+
ButtonIcon,
32
+
type ButtonProps,
33
+
ButtonText,
34
+
} from '#/components/Button'
35
+
import * as Dialog from '#/components/Dialog'
36
+
import * as Toggle from '#/components/forms/Toggle'
37
+
import {Loader} from '#/components/Loader'
38
+
import * as ProfileCard from '#/components/ProfileCard'
39
+
import {Text} from '#/components/Typography'
40
+
import type * as bsky from '#/types/bsky'
41
+
42
+
export function SubscribeProfileDialog({
43
+
control,
44
+
profile,
45
+
moderationOpts,
46
+
includeProfile,
47
+
}: {
48
+
control: Dialog.DialogControlProps
49
+
profile: bsky.profile.AnyProfileView
50
+
moderationOpts: ModerationOpts
51
+
includeProfile?: boolean
52
+
}) {
53
+
return (
54
+
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
55
+
<Dialog.Handle />
56
+
<DialogInner
57
+
profile={profile}
58
+
moderationOpts={moderationOpts}
59
+
includeProfile={includeProfile}
60
+
/>
61
+
</Dialog.Outer>
62
+
)
63
+
}
64
+
65
+
function DialogInner({
66
+
profile,
67
+
moderationOpts,
68
+
includeProfile,
69
+
}: {
70
+
profile: bsky.profile.AnyProfileView
71
+
moderationOpts: ModerationOpts
72
+
includeProfile?: boolean
73
+
}) {
74
+
const {_} = useLingui()
75
+
const t = useTheme()
76
+
const agent = useAgent()
77
+
const control = Dialog.useDialogContext()
78
+
const queryClient = useQueryClient()
79
+
const initialState = parseActivitySubscription(
80
+
profile.viewer?.activitySubscription,
81
+
)
82
+
const [state, setState] = useState(initialState)
83
+
84
+
const values = useMemo(() => {
85
+
const {post, reply} = state
86
+
const res = []
87
+
if (post) res.push('post')
88
+
if (reply) res.push('reply')
89
+
return res
90
+
}, [state])
91
+
92
+
const onChange = (newValues: string[]) => {
93
+
setState(oldValues => {
94
+
// ensure you can't have reply without post
95
+
if (!oldValues.reply && newValues.includes('reply')) {
96
+
return {
97
+
post: true,
98
+
reply: true,
99
+
}
100
+
}
101
+
102
+
if (oldValues.post && !newValues.includes('post')) {
103
+
return {
104
+
post: false,
105
+
reply: false,
106
+
}
107
+
}
108
+
109
+
return {
110
+
post: newValues.includes('post'),
111
+
reply: newValues.includes('reply'),
112
+
}
113
+
})
114
+
}
115
+
116
+
const {
117
+
mutate: saveChanges,
118
+
isPending: isSaving,
119
+
error,
120
+
} = useMutation({
121
+
mutationFn: async (
122
+
activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>,
123
+
) => {
124
+
await agent.app.bsky.notification.putActivitySubscription({
125
+
subject: profile.did,
126
+
activitySubscription,
127
+
})
128
+
},
129
+
onSuccess: (_data, activitySubscription) => {
130
+
control.close(() => {
131
+
updateProfileShadow(queryClient, profile.did, {
132
+
activitySubscription,
133
+
})
134
+
135
+
if (!activitySubscription.post && !activitySubscription.reply) {
136
+
logger.metric('activitySubscription:disable', {})
137
+
Toast.show(
138
+
_(
139
+
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
140
+
),
141
+
'check',
142
+
)
143
+
144
+
// filter out the subscription
145
+
queryClient.setQueryData(
146
+
RQKEY_getActivitySubscriptions,
147
+
(
148
+
old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>,
149
+
) => {
150
+
if (!old) return old
151
+
return {
152
+
...old,
153
+
pages: old.pages.map(page => ({
154
+
...page,
155
+
subscriptions: page.subscriptions.filter(
156
+
item => item.did !== profile.did,
157
+
),
158
+
})),
159
+
}
160
+
},
161
+
)
162
+
} else {
163
+
logger.metric('activitySubscription:enable', {
164
+
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
165
+
})
166
+
if (!initialState.post && !initialState.reply) {
167
+
Toast.show(
168
+
_(
169
+
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
170
+
),
171
+
'check',
172
+
)
173
+
} else {
174
+
Toast.show(_(msg`Changes saved`), 'check')
175
+
}
176
+
}
177
+
})
178
+
},
179
+
onError: err => {
180
+
logger.error('Could not save activity subscription', {message: err})
181
+
},
182
+
})
183
+
184
+
const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => {
185
+
const isDirty =
186
+
state.post !== initialState.post || state.reply !== initialState.reply
187
+
const hasAny = state.post || state.reply
188
+
189
+
if (isDirty) {
190
+
return {
191
+
label: _(msg`Save changes`),
192
+
color: hasAny ? 'primary' : 'negative',
193
+
onPress: () => saveChanges(state),
194
+
disabled: isSaving,
195
+
}
196
+
} else {
197
+
// on web, a disabled save button feels more natural than a massive close button
198
+
if (isWeb) {
199
+
return {
200
+
label: _(msg`Save changes`),
201
+
color: 'secondary',
202
+
disabled: true,
203
+
}
204
+
} else {
205
+
return {
206
+
label: _(msg`Cancel`),
207
+
color: 'secondary',
208
+
onPress: () => control.close(),
209
+
}
210
+
}
211
+
}
212
+
}, [state, initialState, control, _, isSaving, saveChanges])
213
+
214
+
const name = createSanitizedDisplayName(profile, false)
215
+
216
+
return (
217
+
<Dialog.ScrollableInner
218
+
style={web({maxWidth: 400})}
219
+
label={_(msg`Get notified of new posts from ${name}`)}>
220
+
<View style={[a.gap_lg]}>
221
+
<View style={[a.gap_xs]}>
222
+
<Text style={[a.font_heavy, a.text_2xl]}>
223
+
<Trans>Keep me posted</Trans>
224
+
</Text>
225
+
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
226
+
<Trans>Get notified of this account’s activity</Trans>
227
+
</Text>
228
+
</View>
229
+
230
+
{includeProfile && (
231
+
<ProfileCard.Header>
232
+
<ProfileCard.Avatar
233
+
profile={profile}
234
+
moderationOpts={moderationOpts}
235
+
disabledPreview
236
+
/>
237
+
<ProfileCard.NameAndHandle
238
+
profile={profile}
239
+
moderationOpts={moderationOpts}
240
+
/>
241
+
</ProfileCard.Header>
242
+
)}
243
+
244
+
<Toggle.Group
245
+
label={_(msg`Subscribe to account activity`)}
246
+
values={values}
247
+
onChange={onChange}>
248
+
<View style={[a.gap_sm]}>
249
+
<Toggle.Item
250
+
label={_(msg`Posts`)}
251
+
name="post"
252
+
style={[
253
+
a.flex_1,
254
+
a.py_xs,
255
+
platform({
256
+
native: [a.justify_between],
257
+
web: [a.flex_row_reverse, a.gap_sm],
258
+
}),
259
+
]}>
260
+
<Toggle.LabelText
261
+
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
262
+
<Trans>Posts</Trans>
263
+
</Toggle.LabelText>
264
+
<Toggle.Switch />
265
+
</Toggle.Item>
266
+
<Toggle.Item
267
+
label={_(msg`Replies`)}
268
+
name="reply"
269
+
style={[
270
+
a.flex_1,
271
+
a.py_xs,
272
+
platform({
273
+
native: [a.justify_between],
274
+
web: [a.flex_row_reverse, a.gap_sm],
275
+
}),
276
+
]}>
277
+
<Toggle.LabelText
278
+
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
279
+
<Trans>Replies</Trans>
280
+
</Toggle.LabelText>
281
+
<Toggle.Switch />
282
+
</Toggle.Item>
283
+
</View>
284
+
</Toggle.Group>
285
+
286
+
{error && (
287
+
<Admonition type="error">
288
+
<Trans>Could not save changes: {cleanError(error)}</Trans>
289
+
</Admonition>
290
+
)}
291
+
292
+
<Button {...buttonProps} size="large" variant="solid">
293
+
<ButtonText>{buttonProps.label}</ButtonText>
294
+
{isSaving && <ButtonIcon icon={Loader} />}
295
+
</Button>
296
+
</View>
297
+
298
+
<Dialog.Close />
299
+
</Dialog.ScrollableInner>
300
+
)
301
+
}
302
+
303
+
function parseActivitySubscription(
304
+
sub?: AppBskyNotificationDefs.ActivitySubscription,
305
+
): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> {
306
+
if (!sub) return {post: false, reply: false}
307
+
const {post, reply} = sub
308
+
return {post, reply}
309
+
}
+177
src/components/dialogs/nuxs/ActivitySubscriptions.tsx
+177
src/components/dialogs/nuxs/ActivitySubscriptions.tsx
···
1
+
import {useCallback} from 'react'
2
+
import {View} from 'react-native'
3
+
import {Image} from 'expo-image'
4
+
import {msg, Trans} from '@lingui/macro'
5
+
import {useLingui} from '@lingui/react'
6
+
7
+
import {isWeb} from '#/platform/detection'
8
+
import {atoms as a, useTheme, web} from '#/alf'
9
+
import {Button, ButtonText} from '#/components/Button'
10
+
import * as Dialog from '#/components/Dialog'
11
+
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
12
+
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
13
+
import {Text} from '#/components/Typography'
14
+
15
+
export function ActivitySubscriptionsNUX() {
16
+
const t = useTheme()
17
+
const {_} = useLingui()
18
+
const nuxDialogs = useNuxDialogContext()
19
+
const control = Dialog.useDialogControl()
20
+
21
+
Dialog.useAutoOpen(control)
22
+
23
+
const onClose = useCallback(() => {
24
+
nuxDialogs.dismissActiveNux()
25
+
}, [nuxDialogs])
26
+
27
+
return (
28
+
<Dialog.Outer control={control} onClose={onClose}>
29
+
<Dialog.Handle />
30
+
31
+
<Dialog.ScrollableInner
32
+
label={_(msg`Introducing activity notifications`)}
33
+
style={[web({maxWidth: 400})]}
34
+
contentContainerStyle={[
35
+
{
36
+
paddingTop: 0,
37
+
paddingLeft: 0,
38
+
paddingRight: 0,
39
+
},
40
+
]}>
41
+
<View
42
+
style={[
43
+
a.align_center,
44
+
a.overflow_hidden,
45
+
t.atoms.bg_contrast_25,
46
+
{
47
+
gap: isWeb ? 16 : 24,
48
+
paddingTop: isWeb ? 24 : 48,
49
+
borderTopLeftRadius: a.rounded_md.borderRadius,
50
+
borderTopRightRadius: a.rounded_md.borderRadius,
51
+
},
52
+
]}>
53
+
<View
54
+
style={[
55
+
a.pl_sm,
56
+
a.pr_md,
57
+
a.py_sm,
58
+
a.rounded_full,
59
+
a.flex_row,
60
+
a.align_center,
61
+
a.gap_xs,
62
+
{
63
+
backgroundColor: t.palette.primary_100,
64
+
},
65
+
]}>
66
+
<SparkleIcon fill={t.palette.primary_800} size="sm" />
67
+
<Text
68
+
style={[
69
+
a.font_bold,
70
+
{
71
+
color: t.palette.primary_800,
72
+
},
73
+
]}>
74
+
<Trans>New Feature</Trans>
75
+
</Text>
76
+
</View>
77
+
78
+
<View style={[a.relative, a.w_full]}>
79
+
<View
80
+
style={[
81
+
a.absolute,
82
+
t.atoms.bg_contrast_25,
83
+
t.atoms.shadow_md,
84
+
{
85
+
shadowOpacity: 0.4,
86
+
top: 5,
87
+
bottom: 0,
88
+
left: '17%',
89
+
right: '17%',
90
+
width: '66%',
91
+
borderTopLeftRadius: 40,
92
+
borderTopRightRadius: 40,
93
+
},
94
+
]}
95
+
/>
96
+
<View
97
+
style={[
98
+
a.overflow_hidden,
99
+
{
100
+
aspectRatio: 398 / 228,
101
+
},
102
+
]}>
103
+
<Image
104
+
accessibilityIgnoresInvertColors
105
+
source={require('../../../../assets/images/activity_notifications_announcement.webp')}
106
+
style={[
107
+
a.w_full,
108
+
{
109
+
aspectRatio: 398 / 268,
110
+
},
111
+
]}
112
+
alt={_(
113
+
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
114
+
)}
115
+
/>
116
+
</View>
117
+
</View>
118
+
</View>
119
+
<View
120
+
style={[
121
+
a.align_center,
122
+
a.px_xl,
123
+
isWeb ? [a.pt_xl, a.gap_xl, a.pb_sm] : [a.pt_3xl, a.gap_3xl],
124
+
]}>
125
+
<View style={[a.gap_md, a.align_center]}>
126
+
<Text
127
+
style={[
128
+
a.text_3xl,
129
+
a.leading_tight,
130
+
a.font_heavy,
131
+
a.text_center,
132
+
{
133
+
fontSize: isWeb ? 28 : 32,
134
+
maxWidth: 300,
135
+
},
136
+
]}>
137
+
<Trans>Get notified when someone posts</Trans>
138
+
</Text>
139
+
<Text
140
+
style={[
141
+
a.text_md,
142
+
a.leading_snug,
143
+
a.text_center,
144
+
{
145
+
maxWidth: 340,
146
+
},
147
+
]}>
148
+
<Trans>
149
+
You can now choose to be notified when specific people post. If
150
+
there’s someone you want timely updates from, go to their
151
+
profile and find the new bell icon near the follow button.
152
+
</Trans>
153
+
</Text>
154
+
</View>
155
+
156
+
{!isWeb && (
157
+
<Button
158
+
label={_(msg`Close`)}
159
+
size="large"
160
+
variant="solid"
161
+
color="primary"
162
+
onPress={() => {
163
+
control.close()
164
+
}}
165
+
style={[a.w_full, {maxWidth: 280}]}>
166
+
<ButtonText>
167
+
<Trans>Close</Trans>
168
+
</ButtonText>
169
+
</Button>
170
+
)}
171
+
</View>
172
+
173
+
<Dialog.Close />
174
+
</Dialog.ScrollableInner>
175
+
</Dialog.Outer>
176
+
)
177
+
}
+10
-9
src/components/dialogs/nuxs/index.tsx
+10
-9
src/components/dialogs/nuxs/index.tsx
···
11
11
import {useProfileQuery} from '#/state/queries/profile'
12
12
import {type SessionAccount, useSession} from '#/state/session'
13
13
import {useOnboardingState} from '#/state/shell'
14
-
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
14
+
import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
15
15
/*
16
16
* NUXs
17
17
*/
18
18
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
19
-
import {isDaysOld} from '#/components/dialogs/nuxs/utils'
19
+
import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
20
20
21
21
type Context = {
22
22
activeNux: Nux | undefined
···
33
33
}) => boolean
34
34
}[] = [
35
35
{
36
-
id: Nux.InitialVerificationAnnouncement,
36
+
id: Nux.ActivitySubscriptions,
37
37
enabled: ({currentProfile}) => {
38
-
return isDaysOld(2, currentProfile.createdAt)
38
+
return isExistingUserAsOf(
39
+
'2025-07-03T00:00:00.000Z',
40
+
currentProfile.createdAt,
41
+
)
39
42
},
40
43
},
41
44
]
···
111
114
}
112
115
113
116
React.useEffect(() => {
114
-
if (snoozed) return
117
+
if (snoozed) return // comment this out to test
115
118
if (!nuxs) return
116
119
117
120
for (const {id, enabled} of queuedNuxs) {
···
119
122
120
123
// check if completed first
121
124
if (nux && nux.completed) {
122
-
continue
125
+
continue // comment this out to test
123
126
}
124
127
125
128
// then check gate (track exposure)
···
172
175
return (
173
176
<Context.Provider value={ctx}>
174
177
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
175
-
{activeNux === Nux.InitialVerificationAnnouncement && (
176
-
<InitialVerificationAnnouncement />
177
-
)}
178
+
{activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
178
179
</Context.Provider>
179
180
)
180
181
}
+15
src/components/dialogs/nuxs/utils.ts
+15
src/components/dialogs/nuxs/utils.ts
···
16
16
if (isOldEnough) return true
17
17
return false
18
18
}
19
+
20
+
export function isExistingUserAsOf(date: string, createdAt?: string) {
21
+
/*
22
+
* Should never happen because we gate NUXs to only accounts with a valid
23
+
* profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
24
+
* account is either old enough to be pre-onboarding, or some failure happened
25
+
* during account creation. Fail closed. - esb
26
+
*/
27
+
if (!createdAt) return false
28
+
29
+
const threshold = Date.parse(date)
30
+
const then = new Date(createdAt).getTime()
31
+
32
+
return then < threshold
33
+
}
+5
src/components/icons/BellPlus.tsx
+5
src/components/icons/BellPlus.tsx
···
1
+
import {createSinglePathSVG} from './TEMPLATE'
2
+
3
+
export const BellPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
4
+
path: 'M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z',
5
+
})
+4
src/components/icons/BellRinging.tsx
+4
src/components/icons/BellRinging.tsx
···
3
3
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
4
4
path: 'M12 2a7.854 7.854 0 0 1 7.785 6.815l1.055 7.92.018.224a2 2 0 0 1-2 2.041h-2.215c-.904 1.747-2.605 3-4.643 3s-3.739-1.253-4.643-3H5.142a2 2 0 0 1-1.982-2.265l1.056-7.92.057-.363A7.854 7.854 0 0 1 12 2ZM9.78 19c.609.637 1.398 1 2.22 1s1.611-.363 2.22-1H9.78ZM12 4a5.854 5.854 0 0 0-5.76 4.81l-.041.27L5.142 17h13.716l-1.056-7.92A5.854 5.854 0 0 0 12 4ZM2.718 7.464a1 1 0 1 1-1.953-.427l1.953.427Zm20.518-.427a1 1 0 0 1-1.954.427l1.954-.427ZM3.193 2.105a1 1 0 0 1 1.531 1.287 9.47 9.47 0 0 0-2.006 4.072L.765 7.037a11.46 11.46 0 0 1 2.428-4.932Zm16.205-.123a1 1 0 0 1 1.34.047l.069.076.217.265a11.46 11.46 0 0 1 2.212 4.667l-.978.213-.976.214a9.46 9.46 0 0 0-1.826-3.853l-.18-.22-.062-.081a1 1 0 0 1 .184-1.328Z',
5
5
})
6
+
7
+
export const BellRinging_Filled_Corner0_Rounded = createSinglePathSVG({
8
+
path: 'M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.47 9.47 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.47 11.47 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.47 9.47 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z',
9
+
})
+6
-6
src/lib/api/feed/list.ts
+6
-6
src/lib/api/feed/list.ts
···
1
1
import {
2
-
AppBskyFeedDefs,
3
-
AppBskyFeedGetListFeed as GetListFeed,
4
-
BskyAgent,
2
+
type Agent,
3
+
type AppBskyFeedDefs,
4
+
type AppBskyFeedGetListFeed as GetListFeed,
5
5
} from '@atproto/api'
6
6
7
-
import {FeedAPI, FeedAPIResponse} from './types'
7
+
import {type FeedAPI, type FeedAPIResponse} from './types'
8
8
9
9
export class ListFeedAPI implements FeedAPI {
10
-
agent: BskyAgent
10
+
agent: Agent
11
11
params: GetListFeed.QueryParams
12
12
13
13
constructor({
14
14
agent,
15
15
feedParams,
16
16
}: {
17
-
agent: BskyAgent
17
+
agent: Agent
18
18
feedParams: GetListFeed.QueryParams
19
19
}) {
20
20
this.agent = agent
+52
src/lib/api/feed/posts.ts
+52
src/lib/api/feed/posts.ts
···
1
+
import {
2
+
type Agent,
3
+
type AppBskyFeedDefs,
4
+
type AppBskyFeedGetPosts,
5
+
} from '@atproto/api'
6
+
7
+
import {logger} from '#/logger'
8
+
import {type FeedAPI, type FeedAPIResponse} from './types'
9
+
10
+
export class PostListFeedAPI implements FeedAPI {
11
+
agent: Agent
12
+
params: AppBskyFeedGetPosts.QueryParams
13
+
peek: AppBskyFeedDefs.FeedViewPost | null = null
14
+
15
+
constructor({
16
+
agent,
17
+
feedParams,
18
+
}: {
19
+
agent: Agent
20
+
feedParams: AppBskyFeedGetPosts.QueryParams
21
+
}) {
22
+
this.agent = agent
23
+
if (feedParams.uris.length > 25) {
24
+
logger.warn(
25
+
`Too many URIs provided - expected 25, got ${feedParams.uris.length}`,
26
+
)
27
+
}
28
+
this.params = {
29
+
uris: feedParams.uris.slice(0, 25),
30
+
}
31
+
}
32
+
33
+
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
34
+
if (this.peek) return this.peek
35
+
throw new Error('Has not fetched yet')
36
+
}
37
+
38
+
async fetch({}: {}): Promise<FeedAPIResponse> {
39
+
const res = await this.agent.app.bsky.feed.getPosts({
40
+
...this.params,
41
+
})
42
+
if (res.success) {
43
+
this.peek = {post: res.data.posts[0]}
44
+
return {
45
+
feed: res.data.posts.map(post => ({post})),
46
+
}
47
+
}
48
+
return {
49
+
feed: [],
50
+
}
51
+
}
52
+
}
+35
-9
src/lib/hooks/useNotificationHandler.ts
+35
-9
src/lib/hooks/useNotificationHandler.ts
···
1
1
import {useEffect} from 'react'
2
2
import * as Notifications from 'expo-notifications'
3
-
import {type AppBskyNotificationListNotifications} from '@atproto/api'
3
+
import {AtUri} from '@atproto/api'
4
4
import {msg} from '@lingui/macro'
5
5
import {useLingui} from '@lingui/react'
6
6
import {CommonActions, useNavigation} from '@react-navigation/native'
···
32
32
| 'repost-via-repost'
33
33
| 'verified'
34
34
| 'unverified'
35
+
| 'subscribed-post'
35
36
36
37
/**
37
38
* Manually overridden type, but retains the possibility of
···
112
113
})
113
114
114
115
Notifications.setNotificationChannelAsync(
115
-
'like' satisfies AppBskyNotificationListNotifications.Notification['reason'],
116
+
'like' satisfies NotificationReason,
116
117
{
117
118
name: _(msg`Likes`),
118
119
importance: Notifications.AndroidImportance.HIGH,
119
120
},
120
121
)
121
122
Notifications.setNotificationChannelAsync(
122
-
'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
123
+
'repost' satisfies NotificationReason,
123
124
{
124
125
name: _(msg`Reposts`),
125
126
importance: Notifications.AndroidImportance.HIGH,
126
127
},
127
128
)
128
129
Notifications.setNotificationChannelAsync(
129
-
'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'],
130
+
'reply' satisfies NotificationReason,
130
131
{
131
132
name: _(msg`Replies`),
132
133
importance: Notifications.AndroidImportance.HIGH,
133
134
},
134
135
)
135
136
Notifications.setNotificationChannelAsync(
136
-
'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'],
137
+
'mention' satisfies NotificationReason,
137
138
{
138
139
name: _(msg`Mentions`),
139
140
importance: Notifications.AndroidImportance.HIGH,
140
141
},
141
142
)
142
143
Notifications.setNotificationChannelAsync(
143
-
'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'],
144
+
'quote' satisfies NotificationReason,
144
145
{
145
146
name: _(msg`Quotes`),
146
147
importance: Notifications.AndroidImportance.HIGH,
147
148
},
148
149
)
149
150
Notifications.setNotificationChannelAsync(
150
-
'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'],
151
+
'follow' satisfies NotificationReason,
151
152
{
152
153
name: _(msg`New followers`),
153
154
importance: Notifications.AndroidImportance.HIGH,
154
155
},
155
156
)
156
157
Notifications.setNotificationChannelAsync(
157
-
'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
158
+
'like-via-repost' satisfies NotificationReason,
158
159
{
159
160
name: _(msg`Likes of your reposts`),
160
161
importance: Notifications.AndroidImportance.HIGH,
161
162
},
162
163
)
163
164
Notifications.setNotificationChannelAsync(
164
-
'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
165
+
'repost-via-repost' satisfies NotificationReason,
165
166
{
166
167
name: _(msg`Reposts of your reposts`),
167
168
importance: Notifications.AndroidImportance.HIGH,
168
169
},
169
170
)
171
+
Notifications.setNotificationChannelAsync(
172
+
'subscribed-post' satisfies NotificationReason,
173
+
{
174
+
name: _(msg`Activity from others`),
175
+
importance: Notifications.AndroidImportance.HIGH,
176
+
},
177
+
)
170
178
}, [_])
171
179
172
180
useEffect(() => {
···
220
228
}
221
229
} else {
222
230
switch (payload.reason) {
231
+
case 'subscribed-post':
232
+
const urip = new AtUri(payload.uri)
233
+
if (urip.collection === 'app.bsky.feed.post') {
234
+
setTimeout(() => {
235
+
// @ts-expect-error types are weird here
236
+
navigation.navigate('HomeTab', {
237
+
screen: 'PostThread',
238
+
params: {
239
+
name: urip.host,
240
+
rkey: urip.rkey,
241
+
},
242
+
})
243
+
}, 500)
244
+
} else {
245
+
resetToTab('NotificationsTab')
246
+
}
247
+
break
223
248
case 'like':
224
249
case 'repost':
225
250
case 'follow':
···
231
256
case 'repost-via-repost':
232
257
case 'verified':
233
258
case 'unverified':
259
+
default:
234
260
resetToTab('NotificationsTab')
235
261
break
236
262
// TODO implement these after we have an idea of how to handle each individual case
+2
-5
src/lib/moderation/create-sanitized-display-name.ts
+2
-5
src/lib/moderation/create-sanitized-display-name.ts
···
1
-
import {AppBskyActorDefs} from '@atproto/api'
2
-
3
1
import {sanitizeDisplayName} from '#/lib/strings/display-names'
4
2
import {sanitizeHandle} from '#/lib/strings/handles'
3
+
import type * as bsky from '#/types/bsky'
5
4
6
5
export function createSanitizedDisplayName(
7
-
profile:
8
-
| AppBskyActorDefs.ProfileViewBasic
9
-
| AppBskyActorDefs.ProfileViewDetailed,
6
+
profile: bsky.profile.AnyProfileView,
10
7
noAt = false,
11
8
) {
12
9
if (profile.displayName != null && profile.displayName !== '') {
+2
src/lib/routes/types.ts
+2
src/lib/routes/types.ts
···
51
51
AppearanceSettings: undefined
52
52
AccountSettings: undefined
53
53
PrivacyAndSecuritySettings: undefined
54
+
ActivityPrivacySettings: undefined
54
55
ContentAndMediaSettings: undefined
55
56
NotificationSettings: undefined
56
57
ReplyNotificationSettings: undefined
···
72
73
MessagesConversation: {conversation: string; embed?: string; accept?: true}
73
74
MessagesSettings: undefined
74
75
MessagesInbox: undefined
76
+
NotificationsActivityList: {posts: string}
75
77
LegacyNotificationSettings: undefined
76
78
Feeds: undefined
77
79
Start: {name: string; rkey: string}
-1
src/lib/statsig/gates.ts
-1
src/lib/statsig/gates.ts
+13
src/logger/metrics.ts
+13
src/logger/metrics.ts
···
443
443
[key: string]: any
444
444
}
445
445
'thread:click:headerMenuOpen': {}
446
+
'activitySubscription:enable': {
447
+
setting: 'posts' | 'posts_and_replies'
448
+
}
449
+
'activitySubscription:disable': {}
450
+
'activityPreference:changeChannels': {
451
+
name: string
452
+
push: boolean
453
+
list: boolean
454
+
}
455
+
'activityPreference:changeFilter': {
456
+
name: string
457
+
value: string
458
+
}
446
459
}
+2
src/routes.ts
+2
src/routes.ts
···
11
11
Search: '/search',
12
12
Feeds: '/feeds',
13
13
Notifications: '/notifications',
14
+
NotificationsActivityList: '/notifications/activity',
14
15
LegacyNotificationSettings: '/notifications/settings',
15
16
Settings: '/settings',
16
17
Lists: '/lists',
···
50
51
SavedFeeds: '/settings/saved-feeds',
51
52
AccountSettings: '/settings/account',
52
53
PrivacyAndSecuritySettings: '/settings/privacy-and-security',
54
+
ActivityPrivacySettings: '/settings/privacy-and-security/activity',
53
55
ContentAndMediaSettings: '/settings/content-and-media',
54
56
InterestsSettings: '/settings/interests',
55
57
AboutSettings: '/settings/about',
+44
src/screens/Notifications/ActivityList.tsx
+44
src/screens/Notifications/ActivityList.tsx
···
1
+
import {msg, Trans} from '@lingui/macro'
2
+
import {useLingui} from '@lingui/react'
3
+
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
4
+
5
+
import {type AllNavigatorParams} from '#/lib/routes/types'
6
+
import {PostFeed} from '#/view/com/posts/PostFeed'
7
+
import {EmptyState} from '#/view/com/util/EmptyState'
8
+
import * as Layout from '#/components/Layout'
9
+
import {ListFooter} from '#/components/Lists'
10
+
11
+
type Props = NativeStackScreenProps<
12
+
AllNavigatorParams,
13
+
'NotificationsActivityList'
14
+
>
15
+
export function NotificationsActivityListScreen({
16
+
route: {
17
+
params: {posts},
18
+
},
19
+
}: Props) {
20
+
const uris = decodeURIComponent(posts)
21
+
const {_} = useLingui()
22
+
23
+
return (
24
+
<Layout.Screen testID="NotificationsActivityListScreen">
25
+
<Layout.Header.Outer>
26
+
<Layout.Header.BackButton />
27
+
<Layout.Header.Content>
28
+
<Layout.Header.TitleText>
29
+
<Trans>Notifications</Trans>
30
+
</Layout.Header.TitleText>
31
+
</Layout.Header.Content>
32
+
<Layout.Header.Slot />
33
+
</Layout.Header.Outer>
34
+
<PostFeed
35
+
feed={`posts|${uris}`}
36
+
disablePoll
37
+
renderEmptyState={() => (
38
+
<EmptyState icon="growth" message={_(msg`No posts here`)} />
39
+
)}
40
+
renderEndOfFeed={() => <ListFooter />}
41
+
/>
42
+
</Layout.Screen>
43
+
)
44
+
}
+23
-4
src/screens/Profile/Header/ProfileHeaderStandard.tsx
+23
-4
src/screens/Profile/Header/ProfileHeaderStandard.tsx
···
15
15
import {logger} from '#/logger'
16
16
import {isIOS} from '#/platform/detection'
17
17
import {useProfileShadow} from '#/state/cache/profile-shadow'
18
-
import {type Shadow} from '#/state/cache/types'
19
18
import {
20
19
useProfileBlockMutationQueue,
21
20
useProfileFollowMutationQueue,
···
24
23
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
25
24
import * as Toast from '#/view/com/util/Toast'
26
25
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
26
+
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
27
27
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
28
28
import {useDialogControl} from '#/components/Dialog'
29
29
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
···
58
58
}: Props): React.ReactNode => {
59
59
const t = useTheme()
60
60
const {gtMobile} = useBreakpoints()
61
-
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> =
62
-
useProfileShadow(profileUnshadowed)
61
+
const profile =
62
+
useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
63
63
const {currentAccount, hasSession} = useSession()
64
64
const {_} = useLingui()
65
65
const moderation = useMemo(
···
134
134
}
135
135
}, [_, queueUnblock])
136
136
137
-
const isMe = React.useMemo(
137
+
const isMe = useMemo(
138
138
() => currentAccount?.did === profile.did,
139
139
[currentAccount, profile],
140
140
)
141
141
142
142
const {isActive: live} = useActorStatus(profile)
143
143
144
+
const subscriptionsAllowed = useMemo(() => {
145
+
switch (profile.associated?.activitySubscription?.allowSubscriptions) {
146
+
case 'followers':
147
+
case undefined:
148
+
return !!profile.viewer?.following
149
+
case 'mutuals':
150
+
return !!profile.viewer?.following && !!profile.viewer.followedBy
151
+
case 'none':
152
+
default:
153
+
return false
154
+
}
155
+
}, [profile])
156
+
144
157
return (
145
158
<ProfileHeaderShell
146
159
profile={profile}
···
198
211
)
199
212
) : !profile.viewer?.blockedBy ? (
200
213
<>
214
+
{hasSession && subscriptionsAllowed && (
215
+
<SubscribeProfileButton
216
+
profile={profile}
217
+
moderationOpts={moderationOpts}
218
+
/>
219
+
)}
201
220
{hasSession && <MessageProfileButton profile={profile} />}
202
221
203
222
<Button
+2
-17
src/screens/Settings/AccessibilitySettings.tsx
+2
-17
src/screens/Settings/AccessibilitySettings.tsx
···
1
1
import {msg, Trans} from '@lingui/macro'
2
2
import {useLingui} from '@lingui/react'
3
-
import {NativeStackScreenProps} from '@react-navigation/native-stack'
3
+
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
4
4
5
-
import {CommonNavigatorParams} from '#/lib/routes/types'
5
+
import {type CommonNavigatorParams} from '#/lib/routes/types'
6
6
import {isNative} from '#/platform/detection'
7
7
import {
8
8
useHapticsDisabled,
···
16
16
} from '#/state/preferences/large-alt-badge'
17
17
import * as SettingsList from '#/screens/Settings/components/SettingsList'
18
18
import {atoms as a} from '#/alf'
19
-
import {Admonition} from '#/components/Admonition'
20
19
import * as Toggle from '#/components/forms/Toggle'
21
20
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
22
21
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
23
22
import * as Layout from '#/components/Layout'
24
-
import {InlineLinkText} from '#/components/Link'
25
23
26
24
type Props = NativeStackScreenProps<
27
25
CommonNavigatorParams,
···
100
98
</SettingsList.Group>
101
99
</>
102
100
)}
103
-
<SettingsList.Item>
104
-
<Admonition type="info" style={[a.flex_1]}>
105
-
<Trans>
106
-
Autoplay options have moved to the{' '}
107
-
<InlineLinkText
108
-
to="/settings/content-and-media"
109
-
label={_(msg`Content and media`)}>
110
-
Content and Media settings
111
-
</InlineLinkText>
112
-
.
113
-
</Trans>
114
-
</Admonition>
115
-
</SettingsList.Item>
116
101
</SettingsList.Container>
117
102
</Layout.Content>
118
103
</Layout.Screen>
+140
src/screens/Settings/ActivityPrivacySettings.tsx
+140
src/screens/Settings/ActivityPrivacySettings.tsx
···
1
+
import {View} from 'react-native'
2
+
import {type AppBskyNotificationDeclaration} from '@atproto/api'
3
+
import {msg, Trans} from '@lingui/macro'
4
+
import {useLingui} from '@lingui/react'
5
+
6
+
import {
7
+
type AllNavigatorParams,
8
+
type NativeStackScreenProps,
9
+
} from '#/lib/routes/types'
10
+
import {
11
+
useNotificationDeclarationMutation,
12
+
useNotificationDeclarationQuery,
13
+
} from '#/state/queries/activity-subscriptions'
14
+
import {atoms as a, useTheme} from '#/alf'
15
+
import {Admonition} from '#/components/Admonition'
16
+
import * as Toggle from '#/components/forms/Toggle'
17
+
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
18
+
import * as Layout from '#/components/Layout'
19
+
import {Loader} from '#/components/Loader'
20
+
import * as SettingsList from './components/SettingsList'
21
+
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
22
+
23
+
type Props = NativeStackScreenProps<
24
+
AllNavigatorParams,
25
+
'ActivityPrivacySettings'
26
+
>
27
+
export function ActivityPrivacySettingsScreen({}: Props) {
28
+
const {
29
+
data: notificationDeclaration,
30
+
isPending,
31
+
isError,
32
+
} = useNotificationDeclarationQuery()
33
+
34
+
return (
35
+
<Layout.Screen>
36
+
<Layout.Header.Outer>
37
+
<Layout.Header.BackButton />
38
+
<Layout.Header.Content>
39
+
<Layout.Header.TitleText>
40
+
<Trans>Privacy and Security</Trans>
41
+
</Layout.Header.TitleText>
42
+
</Layout.Header.Content>
43
+
<Layout.Header.Slot />
44
+
</Layout.Header.Outer>
45
+
<Layout.Content>
46
+
<SettingsList.Container>
47
+
<SettingsList.Item style={[a.align_start]}>
48
+
<SettingsList.ItemIcon icon={BellRingingIcon} />
49
+
<ItemTextWithSubtitle
50
+
bold
51
+
titleText={
52
+
<Trans>Allow others to be notified of your posts</Trans>
53
+
}
54
+
subtitleText={
55
+
<Trans>
56
+
This feature allows users to receive notifications for your
57
+
new posts and replies. Who do you want to enable this for?
58
+
</Trans>
59
+
}
60
+
/>
61
+
</SettingsList.Item>
62
+
<View style={[a.px_xl, a.pt_md]}>
63
+
{isError ? (
64
+
<Admonition type="error">
65
+
<Trans>Failed to load preference.</Trans>
66
+
</Admonition>
67
+
) : isPending ? (
68
+
<View style={[a.w_full, a.pt_5xl, a.align_center]}>
69
+
<Loader size="xl" />
70
+
</View>
71
+
) : (
72
+
<Inner notificationDeclaration={notificationDeclaration} />
73
+
)}
74
+
</View>
75
+
</SettingsList.Container>
76
+
</Layout.Content>
77
+
</Layout.Screen>
78
+
)
79
+
}
80
+
81
+
export function Inner({
82
+
notificationDeclaration,
83
+
}: {
84
+
notificationDeclaration: {
85
+
uri?: string
86
+
cid?: string
87
+
value: AppBskyNotificationDeclaration.Record
88
+
}
89
+
}) {
90
+
const t = useTheme()
91
+
const {_} = useLingui()
92
+
const {mutate} = useNotificationDeclarationMutation()
93
+
94
+
const onChangeFilter = ([declaration]: string[]) => {
95
+
mutate({
96
+
$type: 'app.bsky.notification.declaration',
97
+
allowSubscriptions: declaration,
98
+
})
99
+
}
100
+
101
+
return (
102
+
<Toggle.Group
103
+
type="radio"
104
+
label={_(
105
+
msg`Filter who can opt to receive notifications for your activity`,
106
+
)}
107
+
values={[notificationDeclaration.value.allowSubscriptions]}
108
+
onChange={onChangeFilter}>
109
+
<View style={[a.gap_sm]}>
110
+
<Toggle.Item
111
+
label={_(msg`Anyone who follows me`)}
112
+
name="followers"
113
+
style={[a.flex_row, a.py_xs, a.gap_sm]}>
114
+
<Toggle.Radio />
115
+
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
116
+
<Trans>Anyone who follows me</Trans>
117
+
</Toggle.LabelText>
118
+
</Toggle.Item>
119
+
<Toggle.Item
120
+
label={_(msg`Only followers who I follow`)}
121
+
name="mutuals"
122
+
style={[a.flex_row, a.py_xs, a.gap_sm]}>
123
+
<Toggle.Radio />
124
+
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
125
+
<Trans>Only followers who I follow</Trans>
126
+
</Toggle.LabelText>
127
+
</Toggle.Item>
128
+
<Toggle.Item
129
+
label={_(msg`No one`)}
130
+
name="none"
131
+
style={[a.flex_row, a.py_xs, a.gap_sm]}>
132
+
<Toggle.Radio />
133
+
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
134
+
<Trans>No one</Trans>
135
+
</Toggle.LabelText>
136
+
</Toggle.Item>
137
+
</View>
138
+
</Toggle.Group>
139
+
)
140
+
}
+3
-3
src/screens/Settings/AppPasswords.tsx
+3
-3
src/screens/Settings/AppPasswords.tsx
···
7
7
LinearTransition,
8
8
StretchOutY,
9
9
} from 'react-native-reanimated'
10
-
import {ComAtprotoServerListAppPasswords} from '@atproto/api'
10
+
import {type ComAtprotoServerListAppPasswords} from '@atproto/api'
11
11
import {msg, Trans} from '@lingui/macro'
12
12
import {useLingui} from '@lingui/react'
13
-
import {NativeStackScreenProps} from '@react-navigation/native-stack'
13
+
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
14
14
15
-
import {CommonNavigatorParams} from '#/lib/routes/types'
15
+
import {type CommonNavigatorParams} from '#/lib/routes/types'
16
16
import {cleanError} from '#/lib/strings/errors'
17
17
import {isWeb} from '#/platform/detection'
18
18
import {
+6
-3
src/screens/Settings/AppearanceSettings.tsx
+6
-3
src/screens/Settings/AppearanceSettings.tsx
···
1
-
import React, {useCallback} from 'react'
1
+
import {useCallback} from 'react'
2
2
import Animated, {
3
3
FadeInUp,
4
4
FadeOutUp,
···
9
9
import {useLingui} from '@lingui/react'
10
10
11
11
import {IS_INTERNAL} from '#/lib/app-info'
12
-
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
12
+
import {
13
+
type CommonNavigatorParams,
14
+
type NativeStackScreenProps,
15
+
} from '#/lib/routes/types'
13
16
import {useGate} from '#/lib/statsig/statsig'
14
17
import {isNative} from '#/platform/detection'
15
18
import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
16
19
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
17
20
import {atoms as a, native, useAlf, useTheme} from '#/alf'
18
21
import * as ToggleButton from '#/components/forms/ToggleButton'
19
-
import {Props as SVGIconProps} from '#/components/icons/common'
22
+
import {type Props as SVGIconProps} from '#/components/icons/common'
20
23
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
21
24
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
22
25
import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
+5
-2
src/screens/Settings/ExternalMediaPreferences.tsx
+5
-2
src/screens/Settings/ExternalMediaPreferences.tsx
···
2
2
import {View} from 'react-native'
3
3
import {Trans} from '@lingui/macro'
4
4
5
-
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
5
+
import {
6
+
type CommonNavigatorParams,
7
+
type NativeStackScreenProps,
8
+
} from '#/lib/routes/types'
6
9
import {
7
-
EmbedPlayerSource,
10
+
type EmbedPlayerSource,
8
11
externalEmbedLabels,
9
12
} from '#/lib/strings/embed-player'
10
13
import {
+4
-1
src/screens/Settings/FollowingFeedPreferences.tsx
+4
-1
src/screens/Settings/FollowingFeedPreferences.tsx
···
1
1
import {msg, Trans} from '@lingui/macro'
2
2
import {useLingui} from '@lingui/react'
3
3
4
-
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
4
+
import {
5
+
type CommonNavigatorParams,
6
+
type NativeStackScreenProps,
7
+
} from '#/lib/routes/types'
5
8
import {
6
9
usePreferencesQuery,
7
10
useSetFeedViewPreferencesMutation,
+263
src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx
+263
src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx
···
1
+
import {useCallback, useMemo} from 'react'
2
+
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
3
+
import {type ModerationOpts} from '@atproto/api'
4
+
import {msg, Trans} from '@lingui/macro'
5
+
import {useLingui} from '@lingui/react'
6
+
7
+
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
8
+
import {
9
+
type AllNavigatorParams,
10
+
type NativeStackScreenProps,
11
+
} from '#/lib/routes/types'
12
+
import {cleanError} from '#/lib/strings/errors'
13
+
import {logger} from '#/logger'
14
+
import {useProfileShadow} from '#/state/cache/profile-shadow'
15
+
import {useModerationOpts} from '#/state/preferences/moderation-opts'
16
+
import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions'
17
+
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
18
+
import {List} from '#/view/com/util/List'
19
+
import {atoms as a, useTheme} from '#/alf'
20
+
import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog'
21
+
import * as Admonition from '#/components/Admonition'
22
+
import {Button, ButtonText} from '#/components/Button'
23
+
import {useDialogControl} from '#/components/Dialog'
24
+
import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging'
25
+
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
26
+
import * as Layout from '#/components/Layout'
27
+
import {InlineLinkText} from '#/components/Link'
28
+
import {ListFooter} from '#/components/Lists'
29
+
import {Loader} from '#/components/Loader'
30
+
import * as ProfileCard from '#/components/ProfileCard'
31
+
import {Text} from '#/components/Typography'
32
+
import type * as bsky from '#/types/bsky'
33
+
import * as SettingsList from '../components/SettingsList'
34
+
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
35
+
import {PreferenceControls} from './components/PreferenceControls'
36
+
37
+
type Props = NativeStackScreenProps<
38
+
AllNavigatorParams,
39
+
'ActivityNotificationSettings'
40
+
>
41
+
export function ActivityNotificationSettingsScreen({}: Props) {
42
+
const t = useTheme()
43
+
const {_} = useLingui()
44
+
const {data: preferences, isError} = useNotificationSettingsQuery()
45
+
46
+
const moderationOpts = useModerationOpts()
47
+
48
+
const {
49
+
data: subscriptions,
50
+
isPending,
51
+
error,
52
+
isFetchingNextPage,
53
+
fetchNextPage,
54
+
hasNextPage,
55
+
} = useActivitySubscriptionsQuery()
56
+
57
+
const items = useMemo(() => {
58
+
if (!subscriptions) return []
59
+
return subscriptions?.pages.flatMap(page => page.subscriptions)
60
+
}, [subscriptions])
61
+
62
+
const renderItem = useCallback(
63
+
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
64
+
if (!moderationOpts) return null
65
+
return (
66
+
<ActivitySubscriptionCard
67
+
profile={item}
68
+
moderationOpts={moderationOpts}
69
+
/>
70
+
)
71
+
},
72
+
[moderationOpts],
73
+
)
74
+
75
+
const onEndReached = useCallback(async () => {
76
+
if (isFetchingNextPage || !hasNextPage || isError) return
77
+
try {
78
+
await fetchNextPage()
79
+
} catch (err) {
80
+
logger.error('Failed to load more likes', {message: err})
81
+
}
82
+
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
83
+
84
+
return (
85
+
<Layout.Screen>
86
+
<Layout.Header.Outer>
87
+
<Layout.Header.BackButton />
88
+
<Layout.Header.Content>
89
+
<Layout.Header.TitleText>
90
+
<Trans>Notifications</Trans>
91
+
</Layout.Header.TitleText>
92
+
</Layout.Header.Content>
93
+
<Layout.Header.Slot />
94
+
</Layout.Header.Outer>
95
+
<List
96
+
ListHeaderComponent={
97
+
<SettingsList.Container>
98
+
<SettingsList.Item style={[a.align_start]}>
99
+
<SettingsList.ItemIcon icon={BellRingingIcon} />
100
+
<ItemTextWithSubtitle
101
+
bold
102
+
titleText={<Trans>Activity from others</Trans>}
103
+
subtitleText={
104
+
<Trans>
105
+
Get notified about posts and replies from accounts you
106
+
choose.
107
+
</Trans>
108
+
}
109
+
/>
110
+
</SettingsList.Item>
111
+
{isError ? (
112
+
<View style={[a.px_lg, a.pt_md]}>
113
+
<Admonition.Admonition type="error">
114
+
<Trans>Failed to load notification settings.</Trans>
115
+
</Admonition.Admonition>
116
+
</View>
117
+
) : (
118
+
<PreferenceControls
119
+
name="subscribedPost"
120
+
preference={preferences?.subscribedPost}
121
+
/>
122
+
)}
123
+
</SettingsList.Container>
124
+
}
125
+
data={items}
126
+
keyExtractor={keyExtractor}
127
+
renderItem={renderItem}
128
+
onEndReached={onEndReached}
129
+
onEndReachedThreshold={4}
130
+
ListEmptyComponent={
131
+
error ? null : (
132
+
<View style={[a.px_xl, a.py_md]}>
133
+
{!isPending ? (
134
+
<Admonition.Outer type="tip">
135
+
<Admonition.Row>
136
+
<Admonition.Icon />
137
+
<View style={[a.flex_1, a.gap_sm]}>
138
+
<Admonition.Text>
139
+
<Trans>
140
+
Enable notifications for an account by visiting their
141
+
profile and pressing the{' '}
142
+
<RNText
143
+
style={[a.font_bold, t.atoms.text_contrast_high]}>
144
+
bell icon
145
+
</RNText>{' '}
146
+
<BellRingingFilledIcon
147
+
size="xs"
148
+
style={t.atoms.text_contrast_high}
149
+
/>
150
+
.
151
+
</Trans>
152
+
</Admonition.Text>
153
+
<Admonition.Text>
154
+
<Trans>
155
+
If you want to restrict who can receive notifications
156
+
for your account's activity, you can change this in{' '}
157
+
<InlineLinkText
158
+
label={_(msg`Privacy and Security settings`)}
159
+
to={{screen: 'ActivityPrivacySettings'}}
160
+
style={[a.font_bold]}>
161
+
Settings → Privacy and Security
162
+
</InlineLinkText>
163
+
.
164
+
</Trans>
165
+
</Admonition.Text>
166
+
</View>
167
+
</Admonition.Row>
168
+
</Admonition.Outer>
169
+
) : (
170
+
<View style={[a.flex_1, a.align_center, a.pt_xl]}>
171
+
<Loader size="lg" />
172
+
</View>
173
+
)}
174
+
</View>
175
+
)
176
+
}
177
+
ListFooterComponent={
178
+
<ListFooter
179
+
style={[items.length === 0 && a.border_transparent]}
180
+
isFetchingNextPage={isFetchingNextPage}
181
+
error={cleanError(error)}
182
+
onRetry={fetchNextPage}
183
+
hasNextPage={hasNextPage}
184
+
/>
185
+
}
186
+
windowSize={11}
187
+
/>
188
+
</Layout.Screen>
189
+
)
190
+
}
191
+
192
+
function keyExtractor(item: bsky.profile.AnyProfileView) {
193
+
return item.did
194
+
}
195
+
196
+
function ActivitySubscriptionCard({
197
+
profile: profileUnshadowed,
198
+
moderationOpts,
199
+
}: {
200
+
profile: bsky.profile.AnyProfileView
201
+
moderationOpts: ModerationOpts
202
+
}) {
203
+
const profile = useProfileShadow(profileUnshadowed)
204
+
const control = useDialogControl()
205
+
const {_} = useLingui()
206
+
const t = useTheme()
207
+
208
+
const preview = useMemo(() => {
209
+
const actSub = profile.viewer?.activitySubscription
210
+
if (actSub?.post && actSub?.reply) {
211
+
return _(msg`Posts, Replies`)
212
+
} else if (actSub?.post) {
213
+
return _(msg`Posts`)
214
+
} else if (actSub?.reply) {
215
+
return _(msg`Replies`)
216
+
}
217
+
return _(msg`None`)
218
+
}, [_, profile.viewer?.activitySubscription])
219
+
220
+
return (
221
+
<View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
222
+
<ProfileCard.Outer>
223
+
<ProfileCard.Header>
224
+
<ProfileCard.Avatar
225
+
profile={profile}
226
+
moderationOpts={moderationOpts}
227
+
/>
228
+
<View style={[a.flex_1, a.gap_2xs]}>
229
+
<ProfileCard.NameAndHandle
230
+
profile={profile}
231
+
moderationOpts={moderationOpts}
232
+
inline
233
+
/>
234
+
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
235
+
{preview}
236
+
</Text>
237
+
</View>
238
+
<Button
239
+
label={_(
240
+
msg`Edit notifications from ${createSanitizedDisplayName(
241
+
profile,
242
+
)}`,
243
+
)}
244
+
size="small"
245
+
color="primary"
246
+
variant="solid"
247
+
onPress={control.open}>
248
+
<ButtonText>
249
+
<Trans>Edit</Trans>
250
+
</ButtonText>
251
+
</Button>
252
+
</ProfileCard.Header>
253
+
</ProfileCard.Outer>
254
+
255
+
<SubscribeProfileDialog
256
+
control={control}
257
+
profile={profile}
258
+
moderationOpts={moderationOpts}
259
+
includeProfile
260
+
/>
261
+
</View>
262
+
)
263
+
}
+13
-17
src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
+13
-17
src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
···
5
5
import {msg, Trans} from '@lingui/macro'
6
6
import {useLingui} from '@lingui/react'
7
7
8
-
import {useGate} from '#/lib/statsig/statsig'
8
+
import {logger} from '#/logger'
9
9
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
10
10
import {atoms as a, platform, useTheme} from '#/alf'
11
11
import * as Toggle from '#/components/forms/Toggle'
···
28
28
preference?: AppBskyNotificationDefs.Preference | FilterablePreference
29
29
allowDisableInApp?: boolean
30
30
}) {
31
-
const gate = useGate()
32
-
33
-
if (!gate('reengagement_features')) return null
34
-
35
31
if (!preference)
36
32
return (
37
33
<View style={[a.w_full, a.pt_5xl, a.align_center]}>
···
78
74
push: change.includes('push'),
79
75
} satisfies typeof preference
80
76
77
+
logger.metric('activityPreference:changeChannels', {
78
+
name,
79
+
push: newPreference.push,
80
+
list: newPreference.list,
81
+
})
82
+
81
83
mutate({
82
84
[name]: newPreference,
83
85
...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
···
92
94
...preference,
93
95
include: change,
94
96
} satisfies typeof preference
97
+
98
+
logger.metric('activityPreference:changeFilter', {name, value: change})
95
99
96
100
mutate({
97
101
[name]: newPreference,
···
114
118
a.py_xs,
115
119
platform({
116
120
native: [a.justify_between],
117
-
web: [a.flex_row_reverse, a.gap_md],
121
+
web: [a.flex_row_reverse, a.gap_sm],
118
122
}),
119
123
]}>
120
124
<Toggle.LabelText
···
131
135
a.py_xs,
132
136
platform({
133
137
native: [a.justify_between],
134
-
web: [a.flex_row_reverse, a.gap_md],
138
+
web: [a.flex_row_reverse, a.gap_sm],
135
139
}),
136
140
]}>
137
141
<Toggle.LabelText
···
159
163
<Toggle.Item
160
164
label={_(msg`Everyone`)}
161
165
name="all"
162
-
style={[
163
-
a.flex_row,
164
-
a.py_xs,
165
-
platform({native: [a.gap_sm], web: [a.gap_md]}),
166
-
]}>
166
+
style={[a.flex_row, a.py_xs, a.gap_sm]}>
167
167
<Toggle.Radio />
168
168
<Toggle.LabelText
169
169
style={[
···
177
177
<Toggle.Item
178
178
label={_(msg`People I follow`)}
179
179
name="follows"
180
-
style={[
181
-
a.flex_row,
182
-
a.py_xs,
183
-
platform({native: [a.gap_sm], web: [a.gap_md]}),
184
-
]}>
180
+
style={[a.flex_row, a.py_xs, a.gap_sm]}>
185
181
<Toggle.Radio />
186
182
<Toggle.LabelText
187
183
style={[
+4
-5
src/screens/Settings/NotificationSettings/index.tsx
+4
-5
src/screens/Settings/NotificationSettings/index.tsx
···
16
16
import {atoms as a} from '#/alf'
17
17
import {Admonition} from '#/components/Admonition'
18
18
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
19
-
// import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
19
+
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
20
20
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
21
21
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
22
22
import {
···
183
183
showSkeleton={!settings}
184
184
/>
185
185
</SettingsList.LinkItem>
186
-
{/* <SettingsList.LinkItem
186
+
<SettingsList.LinkItem
187
187
label={_(msg`Settings for activity alerts`)}
188
188
to={{screen: 'ActivityNotificationSettings'}}
189
189
contentContainerStyle={[a.align_start]}>
190
190
<SettingsList.ItemIcon icon={BellRingingIcon} />
191
-
192
191
<ItemTextWithSubtitle
193
-
titleText={<Trans>Activity alerts</Trans>}
192
+
titleText={<Trans>Activity from others</Trans>}
194
193
subtitleText={
195
194
<SettingPreview preference={settings?.subscribedPost} />
196
195
}
197
196
showSkeleton={!settings}
198
197
/>
199
-
</SettingsList.LinkItem> */}
198
+
</SettingsList.LinkItem>
200
199
<SettingsList.LinkItem
201
200
label={_(
202
201
msg`Settings for notifications for likes of your reposts`,
+50
src/screens/Settings/PrivacyAndSecuritySettings.tsx
+50
src/screens/Settings/PrivacyAndSecuritySettings.tsx
···
1
1
import {View} from 'react-native'
2
+
import {type AppBskyNotificationDeclaration} from '@atproto/api'
2
3
import {msg, Trans} from '@lingui/macro'
3
4
import {useLingui} from '@lingui/react'
4
5
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
5
6
6
7
import {type CommonNavigatorParams} from '#/lib/routes/types'
8
+
import {useNotificationDeclarationQuery} from '#/state/queries/activity-subscriptions'
7
9
import {useAppPasswordsQuery} from '#/state/queries/app-passwords'
8
10
import {useSession} from '#/state/session'
9
11
import * as SettingsList from '#/screens/Settings/components/SettingsList'
10
12
import {atoms as a, useTheme} from '#/alf'
11
13
import * as Admonition from '#/components/Admonition'
14
+
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
12
15
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
13
16
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
14
17
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
···
16
19
import {InlineLinkText} from '#/components/Link'
17
20
import {Email2FAToggle} from './components/Email2FAToggle'
18
21
import {PwiOptOut} from './components/PwiOptOut'
22
+
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
19
23
20
24
type Props = NativeStackScreenProps<
21
25
CommonNavigatorParams,
···
26
30
const t = useTheme()
27
31
const {data: appPasswords} = useAppPasswordsQuery()
28
32
const {currentAccount} = useSession()
33
+
const {
34
+
data: notificationDeclaration,
35
+
isPending,
36
+
isError,
37
+
} = useNotificationDeclarationQuery()
29
38
30
39
return (
31
40
<Layout.Screen>
···
71
80
</SettingsList.BadgeText>
72
81
)}
73
82
</SettingsList.LinkItem>
83
+
<SettingsList.LinkItem
84
+
label={_(msg`Settings for activity alerts`)}
85
+
to={{screen: 'ActivityPrivacySettings'}}
86
+
contentContainerStyle={[a.align_start]}>
87
+
<SettingsList.ItemIcon icon={BellRingingIcon} />
88
+
<ItemTextWithSubtitle
89
+
titleText={
90
+
<Trans>Allow others to be notified of your posts</Trans>
91
+
}
92
+
subtitleText={
93
+
<NotificationDeclaration
94
+
data={notificationDeclaration}
95
+
isError={isError}
96
+
/>
97
+
}
98
+
showSkeleton={isPending}
99
+
/>
100
+
</SettingsList.LinkItem>
74
101
<SettingsList.Divider />
75
102
<SettingsList.Group>
76
103
<SettingsList.ItemIcon icon={EyeSlashIcon} />
···
111
138
</Layout.Screen>
112
139
)
113
140
}
141
+
142
+
function NotificationDeclaration({
143
+
data,
144
+
isError,
145
+
}: {
146
+
data?: {
147
+
value: AppBskyNotificationDeclaration.Record
148
+
}
149
+
isError?: boolean
150
+
}) {
151
+
if (isError) {
152
+
return <Trans>Error loading preference</Trans>
153
+
}
154
+
switch (data?.value?.allowSubscriptions) {
155
+
case 'mutuals':
156
+
return <Trans>Only followers who I follow</Trans>
157
+
case 'none':
158
+
return <Trans>No one</Trans>
159
+
case 'followers':
160
+
default:
161
+
return <Trans>Anyone who follows me</Trans>
162
+
}
163
+
}
+25
-14
src/screens/Settings/Settings.tsx
+25
-14
src/screens/Settings/Settings.tsx
···
3
3
import {Linking} from 'react-native'
4
4
import {useReducedMotion} from 'react-native-reanimated'
5
5
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
6
-
import {msg, t, Trans} from '@lingui/macro'
6
+
import {msg, Trans} from '@lingui/macro'
7
7
import {useLingui} from '@lingui/react'
8
8
import {useNavigation} from '@react-navigation/native'
9
9
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
···
16
16
type CommonNavigatorParams,
17
17
type NavigationProp,
18
18
} from '#/lib/routes/types'
19
-
import {useGate} from '#/lib/statsig/statsig'
20
19
import {sanitizeDisplayName} from '#/lib/strings/display-names'
21
20
import {sanitizeHandle} from '#/lib/strings/handles'
22
21
import {useProfileShadow} from '#/state/cache/profile-shadow'
···
64
63
shouldShowVerificationCheckButton,
65
64
VerificationCheckButton,
66
65
} from '#/components/verification/VerificationCheckButton'
66
+
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
67
67
68
68
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
69
69
export function SettingsScreen({}: Props) {
···
82
82
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
83
83
const [showAccounts, setShowAccounts] = useState(false)
84
84
const [showDevOptions, setShowDevOptions] = useState(false)
85
-
const gate = useGate()
86
85
87
86
return (
88
87
<Layout.Screen>
···
183
182
<Trans>Moderation</Trans>
184
183
</SettingsList.ItemText>
185
184
</SettingsList.LinkItem>
186
-
{gate('reengagement_features') && (
187
-
<SettingsList.LinkItem
188
-
to="/settings/notifications"
189
-
label={_(msg`Notifications`)}>
190
-
<SettingsList.ItemIcon icon={NotificationIcon} />
191
-
<SettingsList.ItemText>
192
-
<Trans>Notifications</Trans>
193
-
</SettingsList.ItemText>
194
-
</SettingsList.LinkItem>
195
-
)}
185
+
<SettingsList.LinkItem
186
+
to="/settings/notifications"
187
+
label={_(msg`Notifications`)}>
188
+
<SettingsList.ItemIcon icon={NotificationIcon} />
189
+
<SettingsList.ItemText>
190
+
<Trans>Notifications</Trans>
191
+
</SettingsList.ItemText>
192
+
</SettingsList.LinkItem>
196
193
<SettingsList.LinkItem
197
194
to="/settings/content-and-media"
198
195
label={_(msg`Content and media`)}>
···
364
361
const onboardingDispatch = useOnboardingDispatch()
365
362
const navigation = useNavigation<NavigationProp>()
366
363
const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
364
+
const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
367
365
368
366
const resetOnboarding = async () => {
369
367
navigation.navigate('Home')
···
384
382
...persisted.get('reminders'),
385
383
lastEmailConfirm: lastEmailConfirm.toISOString(),
386
384
})
387
-
Toast.show(t`You probably want to restart the app now.`)
385
+
Toast.show(_(msg`You probably want to restart the app now.`))
386
+
}
387
+
388
+
const onPressActySubsUnNudge = () => {
389
+
setActyNotifNudged(false)
388
390
}
389
391
390
392
return (
···
431
433
<Trans>Unsnooze email reminder</Trans>
432
434
</SettingsList.ItemText>
433
435
</SettingsList.PressableItem>
436
+
{actyNotifNudged && (
437
+
<SettingsList.PressableItem
438
+
onPress={onPressActySubsUnNudge}
439
+
label={_(msg`Reset activity subscription nudge`)}>
440
+
<SettingsList.ItemText>
441
+
<Trans>Reset activity subscription nudge</Trans>
442
+
</SettingsList.ItemText>
443
+
</SettingsList.PressableItem>
444
+
)}
434
445
<SettingsList.PressableItem
435
446
onPress={() => clearAllStorage()}
436
447
label={_(msg`Clear all storage data`)}>
+14
-9
src/screens/Settings/components/SettingsList.tsx
+14
-9
src/screens/Settings/components/SettingsList.tsx
···
1
-
import React, {useContext, useMemo} from 'react'
2
-
import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'
1
+
import {createContext, useContext, useMemo} from 'react'
2
+
import {
3
+
type GestureResponderEvent,
4
+
type StyleProp,
5
+
View,
6
+
type ViewStyle,
7
+
} from 'react-native'
3
8
4
9
import {HITSLOP_10} from '#/lib/constants'
5
-
import {atoms as a, useTheme, ViewStyleProp} from '#/alf'
10
+
import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
6
11
import * as Button from '#/components/Button'
7
12
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
8
-
import {Link, LinkProps} from '#/components/Link'
13
+
import {Link, type LinkProps} from '#/components/Link'
9
14
import {createPortalGroup} from '#/components/Portal'
10
15
import {Text} from '#/components/Typography'
11
16
12
-
const ItemContext = React.createContext({
17
+
const ItemContext = createContext({
13
18
destructive: false,
14
19
withinGroup: false,
15
20
})
···
91
96
a.px_xl,
92
97
a.py_sm,
93
98
a.align_center,
94
-
a.gap_md,
99
+
a.gap_sm,
95
100
a.w_full,
96
101
a.flex_row,
97
102
{minHeight: 48},
···
100
105
// existing padding
101
106
a.pl_xl.paddingLeft +
102
107
// icon
103
-
28 +
108
+
24 +
104
109
// gap
105
-
a.gap_md.gap,
110
+
a.gap_sm.gap,
106
111
},
107
112
style,
108
113
]}>
···
175
180
176
181
export function ItemIcon({
177
182
icon: Comp,
178
-
size = 'xl',
183
+
size = 'lg',
179
184
color: colorProp,
180
185
}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
181
186
color?: string
+10
-3
src/state/cache/profile-shadow.ts
+10
-3
src/state/cache/profile-shadow.ts
···
1
1
import {useEffect, useMemo, useState} from 'react'
2
-
import {type AppBskyActorDefs} from '@atproto/api'
2
+
import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
3
3
import {type QueryClient} from '@tanstack/react-query'
4
4
import EventEmitter from 'eventemitter3'
5
5
6
6
import {batchedUpdates} from '#/lib/batchedUpdates'
7
+
import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
7
8
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
8
9
import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
9
10
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
···
33
34
blockingUri: string | undefined
34
35
verification: AppBskyActorDefs.VerificationState
35
36
status: AppBskyActorDefs.StatusView | undefined
37
+
activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined
36
38
}
37
39
38
40
const shadows: WeakMap<
···
114
116
value: Partial<ProfileShadow>,
115
117
) {
116
118
const cachedProfiles = findProfilesInCache(queryClient, did)
117
-
for (let post of cachedProfiles) {
118
-
shadows.set(post, {...shadows.get(post), ...value})
119
+
for (let profile of cachedProfiles) {
120
+
shadows.set(profile, {...shadows.get(profile), ...value})
119
121
}
120
122
batchedUpdates(() => {
121
123
emitter.emit(did, value)
···
137
139
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
138
140
blocking:
139
141
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
142
+
activitySubscription:
143
+
'activitySubscription' in shadow
144
+
? shadow.activitySubscription
145
+
: profile.viewer?.activitySubscription,
140
146
},
141
147
verification:
142
148
'verification' in shadow ? shadow.verification : profile.verification,
···
171
177
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
172
178
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
173
179
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
180
+
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
174
181
}
+130
src/state/queries/activity-subscriptions.ts
+130
src/state/queries/activity-subscriptions.ts
···
1
+
import {
2
+
type AppBskyActorDefs,
3
+
type AppBskyNotificationDeclaration,
4
+
type AppBskyNotificationListActivitySubscriptions,
5
+
} from '@atproto/api'
6
+
import {t} from '@lingui/macro'
7
+
import {
8
+
type InfiniteData,
9
+
type QueryClient,
10
+
useInfiniteQuery,
11
+
useMutation,
12
+
useQuery,
13
+
useQueryClient,
14
+
} from '@tanstack/react-query'
15
+
16
+
import {useAgent, useSession} from '#/state/session'
17
+
import * as Toast from '#/view/com/util/Toast'
18
+
19
+
export const RQKEY_getActivitySubscriptions = ['activity-subscriptions']
20
+
export const RQKEY_getNotificationDeclaration = ['notification-declaration']
21
+
22
+
export function useActivitySubscriptionsQuery() {
23
+
const agent = useAgent()
24
+
25
+
return useInfiniteQuery({
26
+
queryKey: RQKEY_getActivitySubscriptions,
27
+
queryFn: async ({pageParam}) => {
28
+
const response =
29
+
await agent.app.bsky.notification.listActivitySubscriptions({
30
+
cursor: pageParam,
31
+
})
32
+
return response.data
33
+
},
34
+
initialPageParam: undefined as string | undefined,
35
+
getNextPageParam: prev => prev.cursor,
36
+
})
37
+
}
38
+
39
+
export function useNotificationDeclarationQuery() {
40
+
const agent = useAgent()
41
+
const {currentAccount} = useSession()
42
+
return useQuery({
43
+
queryKey: RQKEY_getNotificationDeclaration,
44
+
queryFn: async () => {
45
+
try {
46
+
const response = await agent.app.bsky.notification.declaration.get({
47
+
repo: currentAccount!.did,
48
+
rkey: 'self',
49
+
})
50
+
return response
51
+
} catch (err) {
52
+
if (
53
+
err instanceof Error &&
54
+
err.message.startsWith('Could not locate record')
55
+
) {
56
+
return {
57
+
value: {
58
+
$type: 'app.bsky.notification.declaration',
59
+
allowSubscriptions: 'followers',
60
+
} satisfies AppBskyNotificationDeclaration.Record,
61
+
}
62
+
} else {
63
+
throw err
64
+
}
65
+
}
66
+
},
67
+
})
68
+
}
69
+
70
+
export function useNotificationDeclarationMutation() {
71
+
const agent = useAgent()
72
+
const {currentAccount} = useSession()
73
+
const queryClient = useQueryClient()
74
+
return useMutation({
75
+
mutationFn: async (record: AppBskyNotificationDeclaration.Record) => {
76
+
const response = await agent.app.bsky.notification.declaration.put(
77
+
{
78
+
repo: currentAccount!.did,
79
+
rkey: 'self',
80
+
},
81
+
record,
82
+
)
83
+
return response
84
+
},
85
+
onMutate: value => {
86
+
queryClient.setQueryData(
87
+
RQKEY_getNotificationDeclaration,
88
+
(old?: {
89
+
uri: string
90
+
cid: string
91
+
value: AppBskyNotificationDeclaration.Record
92
+
}) => {
93
+
if (!old) return old
94
+
return {
95
+
value,
96
+
}
97
+
},
98
+
)
99
+
},
100
+
onError: () => {
101
+
Toast.show(t`Failed to update notification declaration`)
102
+
queryClient.invalidateQueries({
103
+
queryKey: RQKEY_getNotificationDeclaration,
104
+
})
105
+
},
106
+
})
107
+
}
108
+
109
+
export function* findAllProfilesInQueryData(
110
+
queryClient: QueryClient,
111
+
did: string,
112
+
): Generator<AppBskyActorDefs.ProfileView, void> {
113
+
const queryDatas = queryClient.getQueriesData<
114
+
InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>
115
+
>({
116
+
queryKey: RQKEY_getActivitySubscriptions,
117
+
})
118
+
for (const [_queryKey, queryData] of queryDatas) {
119
+
if (!queryData?.pages) {
120
+
continue
121
+
}
122
+
for (const page of queryData.pages) {
123
+
for (const subscription of page.subscriptions) {
124
+
if (subscription.did === did) {
125
+
yield subscription
126
+
}
127
+
}
128
+
}
129
+
}
130
+
}
+14
-19
src/state/queries/list-members.ts
+14
-19
src/state/queries/list-members.ts
···
1
1
import {
2
-
AppBskyActorDefs,
3
-
AppBskyGraphDefs,
4
-
AppBskyGraphGetList,
5
-
BskyAgent,
2
+
type AppBskyActorDefs,
3
+
type AppBskyGraphDefs,
4
+
type AppBskyGraphGetList,
5
+
type BskyAgent,
6
6
} from '@atproto/api'
7
7
import {
8
-
InfiniteData,
9
-
QueryClient,
10
-
QueryKey,
8
+
type InfiniteData,
9
+
type QueryClient,
10
+
type QueryKey,
11
11
useInfiniteQuery,
12
12
useQuery,
13
13
} from '@tanstack/react-query'
···
100
100
queryKey: [RQKEY_ROOT],
101
101
})
102
102
for (const [_queryKey, queryData] of queryDatas) {
103
-
if (!queryData) {
103
+
if (!queryData?.pages) {
104
104
continue
105
105
}
106
-
for (const [_queryKey, queryData] of queryDatas) {
107
-
if (!queryData?.pages) {
108
-
continue
106
+
for (const page of queryData?.pages) {
107
+
if (page.list.creator.did === did) {
108
+
yield page.list.creator
109
109
}
110
-
for (const page of queryData?.pages) {
111
-
if (page.list.creator.did === did) {
112
-
yield page.list.creator
113
-
}
114
-
for (const item of page.items) {
115
-
if (item.subject.did === did) {
116
-
yield item.subject
117
-
}
110
+
for (const item of page.items) {
111
+
if (item.subject.did === did) {
112
+
yield item.subject
118
113
}
119
114
}
120
115
}
+2
-2
src/state/queries/messages/actor-declaration.ts
+2
-2
src/state/queries/messages/actor-declaration.ts
···
1
-
import {AppBskyActorDefs} from '@atproto/api'
1
+
import {type AppBskyActorDefs} from '@atproto/api'
2
2
import {useMutation, useQueryClient} from '@tanstack/react-query'
3
3
4
4
import {logger} from '#/logger'
···
19
19
return useMutation({
20
20
mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => {
21
21
if (!currentAccount) throw new Error('Not signed in')
22
-
const result = await agent.api.com.atproto.repo.putRecord({
22
+
const result = await agent.com.atproto.repo.putRecord({
23
23
repo: currentAccount.did,
24
24
collection: 'chat.bsky.actor.declaration',
25
25
rkey: 'self',
+7
-7
src/state/queries/notifications/feed.ts
+7
-7
src/state/queries/notifications/feed.ts
···
18
18
19
19
import {useCallback, useEffect, useMemo, useRef} from 'react'
20
20
import {
21
-
AppBskyActorDefs,
21
+
type AppBskyActorDefs,
22
22
AppBskyFeedDefs,
23
23
AppBskyFeedPost,
24
24
AtUri,
25
25
moderatePost,
26
26
} from '@atproto/api'
27
27
import {
28
-
InfiniteData,
29
-
QueryClient,
30
-
QueryKey,
28
+
type InfiniteData,
29
+
type QueryClient,
30
+
type QueryKey,
31
31
useInfiniteQuery,
32
32
useQueryClient,
33
33
} from '@tanstack/react-query'
34
34
35
+
import {useModerationOpts} from '#/state/preferences/moderation-opts'
36
+
import {STALE} from '#/state/queries'
35
37
import {useAgent} from '#/state/session'
36
38
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
37
-
import {useModerationOpts} from '../../preferences/moderation-opts'
38
-
import {STALE} from '..'
39
39
import {
40
40
didOrHandleUriMatches,
41
41
embedViewRecordToPostView,
42
42
getEmbeddedPost,
43
43
} from '../util'
44
-
import {FeedPage} from './types'
44
+
import {type FeedPage} from './types'
45
45
import {useUnreadNotificationsApi} from './unread'
46
46
import {fetchPage} from './util'
47
47
+1
src/state/queries/notifications/types.ts
+1
src/state/queries/notifications/types.ts
+11
-3
src/state/queries/notifications/util.ts
+11
-3
src/state/queries/notifications/util.ts
···
28
28
'follow',
29
29
'like-via-repost',
30
30
'repost-via-repost',
31
+
'subscribed-post',
31
32
]
32
33
const MS_1HR = 1e3 * 60 * 60
33
34
const MS_2DAY = MS_1HR * 48
···
144
145
Math.abs(ts2 - ts) < MS_2DAY &&
145
146
notif.reason === groupedNotif.notification.reason &&
146
147
notif.reasonSubject === groupedNotif.notification.reasonSubject &&
147
-
notif.author.did !== groupedNotif.notification.author.did
148
+
(notif.author.did !== groupedNotif.notification.author.did ||
149
+
notif.reason === 'subscribed-post')
148
150
) {
149
151
const nextIsFollowBack =
150
152
notif.reason === 'follow' && notif.author.viewer?.following
···
252
254
notif.reason === 'verified' ||
253
255
notif.reason === 'unverified' ||
254
256
notif.reason === 'like-via-repost' ||
255
-
notif.reason === 'repost-via-repost'
257
+
notif.reason === 'repost-via-repost' ||
258
+
notif.reason === 'subscribed-post'
256
259
) {
257
260
return notif.reason as NotificationType
258
261
}
···
263
266
type: NotificationType,
264
267
notif: AppBskyNotificationListNotifications.Notification,
265
268
): string | undefined {
266
-
if (type === 'reply' || type === 'quote' || type === 'mention') {
269
+
if (
270
+
type === 'reply' ||
271
+
type === 'quote' ||
272
+
type === 'mention' ||
273
+
type === 'subscribed-post'
274
+
) {
267
275
return notif.uri
268
276
} else if (
269
277
type === 'post-like' ||
+6
src/state/queries/nuxs/definitions.ts
+6
src/state/queries/nuxs/definitions.ts
···
6
6
NeueTypography = 'NeueTypography',
7
7
ExploreInterestsCard = 'ExploreInterestsCard',
8
8
InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
9
+
ActivitySubscriptions = 'ActivitySubscriptions',
9
10
}
10
11
11
12
export const nuxNames = new Set(Object.values(Nux))
···
23
24
id: Nux.InitialVerificationAnnouncement
24
25
data: undefined
25
26
}
27
+
| {
28
+
id: Nux.ActivitySubscriptions
29
+
data: undefined
30
+
}
26
31
>
27
32
28
33
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
29
34
[Nux.NeueTypography]: undefined,
30
35
[Nux.ExploreInterestsCard]: undefined,
31
36
[Nux.InitialVerificationAnnouncement]: undefined,
37
+
[Nux.ActivitySubscriptions]: undefined,
32
38
}
+6
src/state/queries/post-feed.ts
+6
src/state/queries/post-feed.ts
···
24
24
import {LikesFeedAPI} from '#/lib/api/feed/likes'
25
25
import {ListFeedAPI} from '#/lib/api/feed/list'
26
26
import {MergeFeedAPI} from '#/lib/api/feed/merge'
27
+
import {PostListFeedAPI} from '#/lib/api/feed/posts'
27
28
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
28
29
import {aggregateUserInterests} from '#/lib/api/feed/utils'
29
30
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
···
53
54
| 'posts_with_video'
54
55
type FeedUri = string
55
56
type ListUri = string
57
+
type PostsUriList = string
56
58
57
59
export type FeedDescriptor =
58
60
| 'following'
···
60
62
| `feedgen|${FeedUri}`
61
63
| `likes|${ActorDid}`
62
64
| `list|${ListUri}`
65
+
| `posts|${PostsUriList}`
63
66
| 'demo'
64
67
export interface FeedParams {
65
68
mergeFeedEnabled?: boolean
···
488
491
} else if (feedDesc.startsWith('list')) {
489
492
const [_, list] = feedDesc.split('|')
490
493
return new ListFeedAPI({agent, feedParams: {list}})
494
+
} else if (feedDesc.startsWith('posts')) {
495
+
const [_, uriList] = feedDesc.split('|')
496
+
return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
491
497
} else if (feedDesc === 'demo') {
492
498
return new DemoFeedAPI({agent})
493
499
} else {
+8
src/storage/hooks/activity-subscriptions-nudged.ts
+8
src/storage/hooks/activity-subscriptions-nudged.ts
···
1
+
import {device, useStorage} from '#/storage'
2
+
3
+
export function useActivitySubscriptionsNudged() {
4
+
const [activitySubscriptionsNudged = false, setActivitySubscriptionsNudged] =
5
+
useStorage(device, ['activitySubscriptionsNudged'])
6
+
7
+
return [activitySubscriptionsNudged, setActivitySubscriptionsNudged] as const
8
+
}
+1
src/storage/schema.ts
+1
src/storage/schema.ts
+53
-3
src/view/com/notifications/NotificationFeedItem.tsx
+53
-3
src/view/com/notifications/NotificationFeedItem.tsx
···
52
52
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
53
53
import {atoms as a, platform, useTheme} from '#/alf'
54
54
import {Button, ButtonText} from '#/components/Button'
55
+
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
55
56
import {
56
57
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
57
58
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
···
114
115
case 'unverified': {
115
116
return makeProfileLink(item.notification.author)
116
117
}
117
-
case 'reply': {
118
+
case 'reply':
119
+
case 'mention':
120
+
case 'quote': {
118
121
const uripReply = new AtUri(item.notification.uri)
119
122
return `/profile/${uripReply.host}/post/${uripReply.rkey}`
120
123
}
···
125
128
return `/profile/${urip.host}/feed/${urip.rkey}`
126
129
}
127
130
break
131
+
}
132
+
case 'subscribed-post': {
133
+
const posts: string[] = []
134
+
for (const post of [item.notification, ...(item.additional ?? [])]) {
135
+
posts.push(post.uri)
136
+
}
137
+
return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}`
128
138
}
129
139
}
130
140
···
155
165
href: makeProfileLink(author),
156
166
moderation: moderateProfile(author, moderationOpts),
157
167
})) || []),
158
-
]
168
+
].filter(
169
+
(author, index, arr) =>
170
+
arr.findIndex(au => au.profile.did === author.profile.did) === index,
171
+
)
159
172
}, [item, moderationOpts])
160
173
161
174
const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
···
503
516
<Trans>{firstAuthorLink} reposted your repost</Trans>
504
517
)
505
518
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
519
+
} else if (item.type === 'subscribed-post') {
520
+
const postsCount = 1 + (item.additional?.length || 0)
521
+
a11yLabel = hasMultipleAuthors
522
+
? _(
523
+
msg`New posts from ${firstAuthorName} and ${plural(
524
+
additionalAuthorsCount,
525
+
{
526
+
one: `${formattedAuthorsCount} other`,
527
+
other: `${formattedAuthorsCount} others`,
528
+
},
529
+
)}`,
530
+
)
531
+
: _(
532
+
msg`New ${plural(postsCount, {
533
+
one: 'post',
534
+
other: 'posts',
535
+
})} from ${firstAuthorName}`,
536
+
)
537
+
notificationContent = hasMultipleAuthors ? (
538
+
<Trans>
539
+
New posts from {firstAuthorLink} and{' '}
540
+
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
541
+
<Plural
542
+
value={additionalAuthorsCount}
543
+
one={`${formattedAuthorsCount} other`}
544
+
other={`${formattedAuthorsCount} others`}
545
+
/>
546
+
</Text>{' '}
547
+
</Trans>
548
+
) : (
549
+
<Trans>
550
+
New <Plural value={postsCount} one="post" other="posts" /> from{' '}
551
+
{firstAuthorLink}
552
+
</Trans>
553
+
)
554
+
icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} />
506
555
} else {
507
556
return null
508
557
}
···
613
662
{item.type === 'post-like' ||
614
663
item.type === 'repost' ||
615
664
item.type === 'like-via-repost' ||
616
-
item.type === 'repost-via-repost' ? (
665
+
item.type === 'repost-via-repost' ||
666
+
item.type === 'subscribed-post' ? (
617
667
<View style={[a.pt_2xs]}>
618
668
<AdditionalPostText post={item.subject} />
619
669
</View>
+2
-1
src/view/com/util/PostMeta.tsx
+2
-1
src/view/com/util/PostMeta.tsx
+87
-87
yarn.lock
+87
-87
yarn.lock
···
63
63
"@atproto/xrpc" "^0.7.0"
64
64
"@atproto/xrpc-server" "^0.8.0"
65
65
66
-
"@atproto/api@^0.15.16":
67
-
version "0.15.16"
68
-
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.16.tgz#1962e7067e03a661e17c3164874596ef1e7ed7ad"
69
-
integrity sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ==
66
+
"@atproto/api@^0.15.21":
67
+
version "0.15.21"
68
+
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd"
69
+
integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA==
70
70
dependencies:
71
71
"@atproto/common-web" "^0.4.2"
72
72
"@atproto/lexicon" "^0.4.11"
···
77
77
tlds "^1.234.0"
78
78
zod "^3.23.8"
79
79
80
-
"@atproto/aws@^0.2.22":
81
-
version "0.2.22"
82
-
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.22.tgz#14a664c06e3569945e4ab143d3a8a03400c7d1de"
83
-
integrity sha512-xZ+0/zHHmpgzdLJGTDkFl5Wd39Wm5MyyMLdGYSzyt0wGTBmH6Ktp7ZgR8rmQVNYN1+VkMcdClAiNhg+BSH3mRw==
80
+
"@atproto/aws@^0.2.24":
81
+
version "0.2.24"
82
+
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891"
83
+
integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ==
84
84
dependencies:
85
85
"@atproto/common" "^0.4.11"
86
86
"@atproto/crypto" "^0.4.4"
87
-
"@atproto/repo" "^0.8.2"
87
+
"@atproto/repo" "^0.8.4"
88
88
"@aws-sdk/client-cloudfront" "^3.261.0"
89
89
"@aws-sdk/client-kms" "^3.196.0"
90
90
"@aws-sdk/client-s3" "^3.224.0"
···
94
94
multiformats "^9.9.0"
95
95
uint8arrays "3.0.0"
96
96
97
-
"@atproto/bsky@^0.0.161":
98
-
version "0.0.161"
99
-
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.161.tgz#671280c1d40f5c4eb0cc31d338a9e950acbf0ce0"
100
-
integrity sha512-L4uzadjt+oyVq3+W7rc1A+X2DyZDsTfeSD15w7k6+6JzICp32qavDuVjut3CIBqXCt7ykvSDujApyLsB/lcWJQ==
97
+
"@atproto/bsky@^0.0.167":
98
+
version "0.0.167"
99
+
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0"
100
+
integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA==
101
101
dependencies:
102
102
"@atproto-labs/fetch-node" "0.1.9"
103
103
"@atproto-labs/xrpc-utils" "0.0.16"
104
-
"@atproto/api" "^0.15.16"
104
+
"@atproto/api" "^0.15.21"
105
105
"@atproto/common" "^0.4.11"
106
106
"@atproto/crypto" "^0.4.4"
107
107
"@atproto/did" "^0.1.5"
108
108
"@atproto/identity" "^0.4.8"
109
109
"@atproto/lexicon" "^0.4.11"
110
-
"@atproto/repo" "^0.8.2"
111
-
"@atproto/sync" "^0.1.26"
110
+
"@atproto/repo" "^0.8.4"
111
+
"@atproto/sync" "^0.1.28"
112
112
"@atproto/syntax" "^0.4.0"
113
113
"@atproto/xrpc-server" "^0.8.0"
114
114
"@bufbuild/protobuf" "^1.5.0"
···
218
218
"@noble/hashes" "^1.6.1"
219
219
uint8arrays "3.0.0"
220
220
221
-
"@atproto/dev-env@^0.3.144":
222
-
version "0.3.144"
223
-
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.144.tgz#cd2949ff870ca4cde23b4c377b08740a2e64151f"
224
-
integrity sha512-ND0oGp7itSnXxlAHlFxYjGFyCcu0f4eSucImVtKRxTcW8UeyyTtJcQP8OyNvtC8j13YjbW124r0g25Wlm0j9XQ==
221
+
"@atproto/dev-env@^0.3.150":
222
+
version "0.3.150"
223
+
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a"
224
+
integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ==
225
225
dependencies:
226
-
"@atproto/api" "^0.15.16"
227
-
"@atproto/bsky" "^0.0.161"
226
+
"@atproto/api" "^0.15.21"
227
+
"@atproto/bsky" "^0.0.167"
228
228
"@atproto/bsync" "^0.0.20"
229
229
"@atproto/common-web" "^0.4.2"
230
230
"@atproto/crypto" "^0.4.4"
231
231
"@atproto/identity" "^0.4.8"
232
232
"@atproto/lexicon" "^0.4.11"
233
-
"@atproto/ozone" "^0.1.121"
234
-
"@atproto/pds" "^0.4.150"
235
-
"@atproto/sync" "^0.1.26"
233
+
"@atproto/ozone" "^0.1.126"
234
+
"@atproto/pds" "^0.4.156"
235
+
"@atproto/sync" "^0.1.28"
236
236
"@atproto/syntax" "^0.4.0"
237
237
"@atproto/xrpc-server" "^0.8.0"
238
238
"@did-plc/lib" "^0.0.1"
···
259
259
"@atproto/common-web" "^0.4.2"
260
260
"@atproto/crypto" "^0.4.4"
261
261
262
-
"@atproto/jwk-jose@0.1.8":
263
-
version "0.1.8"
264
-
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.8.tgz#2dc8ad2cc900e7bc231add293f6518b06dc017ec"
265
-
integrity sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA==
262
+
"@atproto/jwk-jose@0.1.9":
263
+
version "0.1.9"
264
+
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz#bd4a899ea2d497808300c40106795f5645c01f75"
265
+
integrity sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ==
266
266
dependencies:
267
-
"@atproto/jwk" "0.3.0"
267
+
"@atproto/jwk" "0.4.0"
268
268
jose "^5.2.0"
269
269
270
-
"@atproto/jwk@0.3.0":
271
-
version "0.3.0"
272
-
resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.3.0.tgz#275fa676f6b5988ddedf4ee0475dd285de9b831b"
273
-
integrity sha512-MIAXyNMGu1tCNHjqW/8jqfE/wgWCIoK2cJ0mR6UxwhNPvkbe35TcpRYJdtQu/E6MUd7TziyDBa/GO4dKAiePhQ==
270
+
"@atproto/jwk@0.4.0":
271
+
version "0.4.0"
272
+
resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.4.0.tgz#f32265be172492c38434c556a124b954f249cee8"
273
+
integrity sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog==
274
274
dependencies:
275
275
multiformats "^9.9.0"
276
276
zod "^3.23.8"
···
286
286
multiformats "^9.9.0"
287
287
zod "^3.23.8"
288
288
289
-
"@atproto/oauth-provider-api@0.1.4":
290
-
version "0.1.4"
291
-
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.4.tgz#a775182e3648dc693a04e3cb604eb62cd9ddfd8c"
292
-
integrity sha512-3PRrf0gTAVMCETjtIH/3AaQaHBDbjsRBc/OYrlWBZ9IPplchBXtQGH/KcnjE4kK2Ef8p45qQSl3dNWg3EXsbHQ==
289
+
"@atproto/oauth-provider-api@0.1.6":
290
+
version "0.1.6"
291
+
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.6.tgz#769a70caaac9b5144f9f867518523d1568a6b47c"
292
+
integrity sha512-4Q6ZCnTmmdiWiA+KMrfbZmqjxTSgMe+YE68+3RccwOCIgPt171TiDHGKIayep9n1RDnuucVQoqvVXOT4kmAsjw==
293
293
dependencies:
294
-
"@atproto/jwk" "0.3.0"
295
-
"@atproto/oauth-types" "0.3.0"
294
+
"@atproto/jwk" "0.4.0"
295
+
"@atproto/oauth-types" "0.4.0"
296
296
297
-
"@atproto/oauth-provider-frontend@0.1.8":
298
-
version "0.1.8"
299
-
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.8.tgz#21d944566c63f54524f239a10f7c65d150982f40"
300
-
integrity sha512-uqfHv+n2xq7vTpuBP1Red7PhpaAbbJbwSbRsSfplJQ16XmF5NCMU8dHGCGRTEHngLZ9UquuIefN3w1QTrNzD0w==
297
+
"@atproto/oauth-provider-frontend@0.1.10":
298
+
version "0.1.10"
299
+
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.10.tgz#d7176819d0ae1401ca5d70f7afec253621901a79"
300
+
integrity sha512-bOFpi5OIxWv4Q9ci1+PAXEzIZaiu5inepC7pRFYqgqgLoCO0MWH/5Qkn/f6jMpDwPdtBqAiPg9tjE7E3le6NJA==
301
301
optionalDependencies:
302
-
"@atproto/oauth-provider-api" "0.1.4"
302
+
"@atproto/oauth-provider-api" "0.1.6"
303
303
304
-
"@atproto/oauth-provider-ui@0.1.9":
305
-
version "0.1.9"
306
-
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.9.tgz#8c43a1affa94ecb537072e6d569b8a24cdd42e72"
307
-
integrity sha512-a6/VAeQWRMxpgnqo/TuqXg3EW2tO68jLh8Mv1uyV1NiZbT7fNlgkII/djIl3fLoEa95I3p236NZxjhKELSBbGg==
304
+
"@atproto/oauth-provider-ui@0.1.11":
305
+
version "0.1.11"
306
+
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.11.tgz#cb6194ac0b93f1d4b5d6717f80c55a3a20a8c690"
307
+
integrity sha512-9fflyDt4Y3RDJIfbonxVeMbQtLLQrkQSDhWhPXp9xbZ/uYBddaAw+svBfFoMY7dxdlJbQeUPobsUctEm3qAILg==
308
308
optionalDependencies:
309
-
"@atproto/oauth-provider-api" "0.1.4"
309
+
"@atproto/oauth-provider-api" "0.1.6"
310
310
311
-
"@atproto/oauth-provider@^0.9.1":
312
-
version "0.9.1"
313
-
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.1.tgz#0147b75d1ad444455159f0a687ce87b3b49a2894"
314
-
integrity sha512-2Gm3jv45cGLmUQV0C4/orCJBsHu4wajy+JTN9f/ATX3vvjnFtAd/1GRvAMKDGXtdF7VIjNFlD+4lqhoDxYJpng==
311
+
"@atproto/oauth-provider@^0.9.3":
312
+
version "0.9.3"
313
+
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.3.tgz#047b2e520e5cf127385adddc1dca47207b0ca113"
314
+
integrity sha512-TAhsCYDB/1twEA1vqjLAz7lxKI8W59eNs239MujE35Cc9l4lRHyMopoFv5JmgNnxDvloB5l6RxpTbXVC6wnKpQ==
315
315
dependencies:
316
316
"@atproto-labs/fetch" "0.2.3"
317
317
"@atproto-labs/fetch-node" "0.1.9"
···
320
320
"@atproto-labs/simple-store-memory" "0.1.3"
321
321
"@atproto/common" "^0.4.11"
322
322
"@atproto/did" "0.1.5"
323
-
"@atproto/jwk" "0.3.0"
324
-
"@atproto/jwk-jose" "0.1.8"
325
-
"@atproto/oauth-provider-api" "0.1.4"
326
-
"@atproto/oauth-provider-frontend" "0.1.8"
327
-
"@atproto/oauth-provider-ui" "0.1.9"
328
-
"@atproto/oauth-types" "0.3.0"
323
+
"@atproto/jwk" "0.4.0"
324
+
"@atproto/jwk-jose" "0.1.9"
325
+
"@atproto/oauth-provider-api" "0.1.6"
326
+
"@atproto/oauth-provider-frontend" "0.1.10"
327
+
"@atproto/oauth-provider-ui" "0.1.11"
328
+
"@atproto/oauth-types" "0.4.0"
329
329
"@atproto/syntax" "0.4.0"
330
330
"@hapi/accept" "^6.0.3"
331
331
"@hapi/address" "^5.1.1"
···
339
339
jose "^5.2.0"
340
340
zod "^3.23.8"
341
341
342
-
"@atproto/oauth-types@0.3.0":
343
-
version "0.3.0"
344
-
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.3.0.tgz#8d49d939486ac281bc13d0b1fe4462b7e519fdf0"
345
-
integrity sha512-ptfsJARKODXfuOoDQag4a6PpEkDEj4Urz3jOmnQZy2YspPc/TNm1o0HglU0YehELv1vfhh9gEz40BJztPPhiLA==
342
+
"@atproto/oauth-types@0.4.0":
343
+
version "0.4.0"
344
+
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.4.0.tgz#fb110717dd1e8593adffc6eaa85e7ab4f0713740"
345
+
integrity sha512-FrRH9JsPw9H4JxfPDrbrI+pB102tbHTygajfHay7xwz78HPOjSbWPRgWW2hYS4w8vDYdB3PYbBj1jPoKetW7LA==
346
346
dependencies:
347
-
"@atproto/jwk" "0.3.0"
347
+
"@atproto/jwk" "0.4.0"
348
348
zod "^3.23.8"
349
349
350
-
"@atproto/ozone@^0.1.121":
351
-
version "0.1.121"
352
-
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.121.tgz#309b7e876f3b598ed4e79bb5a79e2346931588fe"
353
-
integrity sha512-kc3NxiXSPqQmWz8yXlV5cFnZ469ViQd0AexEMw467AcB8ikK1WSxhLsa1EiNAQuLOOpyeXSmAKGAUFHzSOIMpw==
350
+
"@atproto/ozone@^0.1.126":
351
+
version "0.1.126"
352
+
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd"
353
+
integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ==
354
354
dependencies:
355
-
"@atproto/api" "^0.15.16"
355
+
"@atproto/api" "^0.15.21"
356
356
"@atproto/common" "^0.4.11"
357
357
"@atproto/crypto" "^0.4.4"
358
358
"@atproto/identity" "^0.4.8"
···
377
377
undici "^6.14.1"
378
378
ws "^8.12.0"
379
379
380
-
"@atproto/pds@^0.4.150":
381
-
version "0.4.150"
382
-
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.150.tgz#45686b05b8ed46e265efa5231ab16e6eda72a8e8"
383
-
integrity sha512-CPT6H2uDTe4ZAyxQbws2dIlmdFFf6GQGwMc0OE3kI1wBBaLHprpexjM2Gd4ObtYNxGOOV0fwoCDAth8qqZ4XVw==
380
+
"@atproto/pds@^0.4.156":
381
+
version "0.4.156"
382
+
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392"
383
+
integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w==
384
384
dependencies:
385
385
"@atproto-labs/fetch-node" "0.1.9"
386
386
"@atproto-labs/xrpc-utils" "0.0.16"
387
-
"@atproto/api" "^0.15.16"
388
-
"@atproto/aws" "^0.2.22"
387
+
"@atproto/api" "^0.15.21"
388
+
"@atproto/aws" "^0.2.24"
389
389
"@atproto/common" "^0.4.11"
390
390
"@atproto/crypto" "^0.4.4"
391
391
"@atproto/identity" "^0.4.8"
392
392
"@atproto/lexicon" "^0.4.11"
393
-
"@atproto/oauth-provider" "^0.9.1"
394
-
"@atproto/repo" "^0.8.2"
393
+
"@atproto/oauth-provider" "^0.9.3"
394
+
"@atproto/repo" "^0.8.4"
395
395
"@atproto/syntax" "^0.4.0"
396
396
"@atproto/xrpc" "^0.7.0"
397
397
"@atproto/xrpc-server" "^0.8.0"
···
424
424
undici "^6.19.8"
425
425
zod "^3.23.8"
426
426
427
-
"@atproto/repo@^0.8.2":
428
-
version "0.8.2"
429
-
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.2.tgz#7953cb2c637c94505da76f74a784b2aae050c204"
430
-
integrity sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg==
427
+
"@atproto/repo@^0.8.4":
428
+
version "0.8.4"
429
+
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774"
430
+
integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA==
431
431
dependencies:
432
432
"@atproto/common" "^0.4.11"
433
433
"@atproto/common-web" "^0.4.2"
···
439
439
varint "^6.0.0"
440
440
zod "^3.23.8"
441
441
442
-
"@atproto/sync@^0.1.26":
443
-
version "0.1.26"
444
-
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.26.tgz#6be2876be612d9cd704452598ee679b2e912cfe3"
445
-
integrity sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA==
442
+
"@atproto/sync@^0.1.28":
443
+
version "0.1.28"
444
+
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf"
445
+
integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA==
446
446
dependencies:
447
447
"@atproto/common" "^0.4.11"
448
448
"@atproto/identity" "^0.4.8"
449
449
"@atproto/lexicon" "^0.4.11"
450
-
"@atproto/repo" "^0.8.2"
450
+
"@atproto/repo" "^0.8.4"
451
451
"@atproto/syntax" "^0.4.0"
452
452
"@atproto/xrpc-server" "^0.8.0"
453
453
multiformats "^9.9.0"