tangled mirror of catsky-🐱 Soothing soft social-app fork with all the niche toggles! (Unofficial); for issues and PRs please put them on github:NekoDrone/catsky-social

Activity notification settings (#8485)

Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: hailey <me@haileyok.com>

authored by samuel.fm Eric Bailey samuel.fm hailey and committed by GitHub bc072570 8f9a8ddc

+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 + <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

This is a binary file and will not be displayed.

+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
··· 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
··· 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",
+24
src/Navigation.tsx
··· 84 84 import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' 85 85 import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' 86 86 import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' 87 + import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings' 87 88 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' 88 89 import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' 89 90 import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' ··· 109 110 } from '#/components/dialogs/EmailDialog' 110 111 import {router} from '#/routes' 111 112 import {Referrer} from '../modules/expo-bluesky-swiss-army' 113 + import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList' 112 114 import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' 113 115 import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' 116 + import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings' 114 117 import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings' 115 118 import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings' 116 119 import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings' ··· 391 394 }} 392 395 /> 393 396 <Stack.Screen 397 + name="ActivityPrivacySettings" 398 + getComponent={() => ActivityPrivacySettingsScreen} 399 + options={{ 400 + title: title(msg`Privacy and Security`), 401 + requireAuth: true, 402 + }} 403 + /> 404 + <Stack.Screen 394 405 name="NotificationSettings" 395 406 getComponent={() => NotificationSettingsScreen} 396 407 options={{title: title(msg`Notification settings`), requireAuth: true}} ··· 460 471 }} 461 472 /> 462 473 <Stack.Screen 474 + name="ActivityNotificationSettings" 475 + getComponent={() => ActivityNotificationSettingsScreen} 476 + options={{ 477 + title: title(msg`Activity notifications`), 478 + requireAuth: true, 479 + }} 480 + /> 481 + <Stack.Screen 463 482 name="MiscellaneousNotificationSettings" 464 483 getComponent={() => MiscellaneousNotificationSettingsScreen} 465 484 options={{ ··· 523 542 name="MessagesInbox" 524 543 getComponent={() => MessagesInboxScreen} 525 544 options={{title: title(msg`Chat request inbox`), requireAuth: true}} 545 + /> 546 + <Stack.Screen 547 + name="NotificationsActivityList" 548 + getComponent={() => NotificationsActivityListScreen} 549 + options={{title: title(msg`Notifications`), requireAuth: true}} 526 550 /> 527 551 <Stack.Screen 528 552 name="LegacyNotificationSettings"
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 7 7 | 'old_postonboarding' 8 8 | 'onboarding_add_video_feed' 9 9 | 'post_threads_v2_unspecced' 10 - | 'reengagement_features' 11 10 | 'remove_show_latest_button' 12 11 | 'test_gate_1' 13 12 | 'test_gate_2'
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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 &rarr; 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 48 48 | 'unverified' 49 49 | 'like-via-repost' 50 50 | 'repost-via-repost' 51 + | 'subscribed-post' 51 52 | 'unknown' 52 53 53 54 type FeedNotificationBase = {
+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 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
··· 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
··· 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
··· 11 11 trendingBetaEnabled: boolean 12 12 devMode: boolean 13 13 demoMode: boolean 14 + activitySubscriptionsNudged?: boolean 14 15 } 15 16 16 17 export type Account = {
+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
··· 96 96 a.font_bold, 97 97 t.atoms.text, 98 98 a.leading_tight, 99 - {maxWidth: '70%', flexShrink: 0}, 99 + a.flex_shrink_0, 100 + {maxWidth: '70%'}, 100 101 ]}> 101 102 {forceLTR( 102 103 sanitizeDisplayName(
+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"