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>
This commit is contained in:
Samuel Newman
2025-07-02 00:36:04 +03:00
committed by GitHub
parent 8f9a8ddce0
commit bc072570d2
53 changed files with 1810 additions and 288 deletions
@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +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>

After

Width:  |  Height:  |  Size: 594 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+2
View File
@@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error {
e.GET("/feeds", server.WebGeneric) e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric) e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric) e.GET("/notifications/settings", server.WebGeneric)
e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric) e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric) e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric) e.GET("/moderation/modlists", server.WebGeneric)
@@ -275,6 +276,7 @@ func serve(cctx *cli.Context) error {
e.GET("/settings/appearance", server.WebGeneric) e.GET("/settings/appearance", server.WebGeneric)
e.GET("/settings/account", server.WebGeneric) e.GET("/settings/account", server.WebGeneric)
e.GET("/settings/privacy-and-security", server.WebGeneric) e.GET("/settings/privacy-and-security", server.WebGeneric)
e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
e.GET("/settings/content-and-media", server.WebGeneric) e.GET("/settings/content-and-media", server.WebGeneric)
e.GET("/settings/interests", server.WebGeneric) e.GET("/settings/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric) e.GET("/settings/about", server.WebGeneric)
@@ -45,10 +45,10 @@ class BackgroundNotificationHandler(
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) { private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher // If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
// assign to it's eponymous channel. otherwise do nothing, let expo handle it // assign to it's eponymous channel. otherwise do nothing, let expo handle it
when (remoteMessage.data["reason"]) { when (remoteMessage.data["reason"]) {
"like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> { "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
remoteMessage.data["channelId"] = remoteMessage.data["reason"] remoteMessage.data["channelId"] = remoteMessage.data["reason"]
} }
} }
+2 -2
View File
@@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons" "icons:optimize": "svgo -f ./assets/icons"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.15.16", "@atproto/api": "^0.15.21",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
"zod": "^3.20.2" "zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@atproto/dev-env": "^0.3.144", "@atproto/dev-env": "^0.3.150",
"@babel/core": "^7.26.0", "@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0", "@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0", "@babel/runtime": "^7.26.0",
+24
View File
@@ -84,6 +84,7 @@ import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings' import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings' import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings' import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings' import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords' import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
@@ -109,8 +110,10 @@ import {
} from '#/components/dialogs/EmailDialog' } from '#/components/dialogs/EmailDialog'
import {router} from '#/routes' import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army' import {Referrer} from '../modules/expo-bluesky-swiss-army'
import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList'
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings' import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings' import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings' import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings' import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings' import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
@@ -390,6 +393,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, requireAuth: true,
}} }}
/> />
<Stack.Screen
name="ActivityPrivacySettings"
getComponent={() => ActivityPrivacySettingsScreen}
options={{
title: title(msg`Privacy and Security`),
requireAuth: true,
}}
/>
<Stack.Screen <Stack.Screen
name="NotificationSettings" name="NotificationSettings"
getComponent={() => NotificationSettingsScreen} getComponent={() => NotificationSettingsScreen}
@@ -459,6 +470,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true, requireAuth: true,
}} }}
/> />
<Stack.Screen
name="ActivityNotificationSettings"
getComponent={() => ActivityNotificationSettingsScreen}
options={{
title: title(msg`Activity notifications`),
requireAuth: true,
}}
/>
<Stack.Screen <Stack.Screen
name="MiscellaneousNotificationSettings" name="MiscellaneousNotificationSettings"
getComponent={() => MiscellaneousNotificationSettingsScreen} getComponent={() => MiscellaneousNotificationSettingsScreen}
@@ -524,6 +543,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesInboxScreen} getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}} options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/> />
<Stack.Screen
name="NotificationsActivityList"
getComponent={() => NotificationsActivityListScreen}
options={{title: title(msg`Notifications`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="LegacyNotificationSettings" name="LegacyNotificationSettings"
getComponent={() => LegacyNotificationSettingsScreen} getComponent={() => LegacyNotificationSettingsScreen}
+66 -1
View File
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status' import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import {type LogEvents} from '#/lib/statsig/statsig' import {type LogEvents} from '#/lib/statsig/statsig'
import {forceLTR} from '#/lib/strings/bidi'
import {NON_BREAKING_SPACE} from '#/lib/strings/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import { import {
Button, Button,
ButtonIcon, ButtonIcon,
@@ -183,16 +185,79 @@ export function AvatarPlaceholder() {
export function NameAndHandle({ export function NameAndHandle({
profile, profile,
moderationOpts, moderationOpts,
inline = false,
}: { }: {
profile: bsky.profile.AnyProfileView profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts moderationOpts: ModerationOpts
inline?: boolean
}) { }) {
if (inline) {
return (
<InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
)
} else {
return ( return (
<View style={[a.flex_1]}> <View style={[a.flex_1]}>
<Name profile={profile} moderationOpts={moderationOpts} /> <Name profile={profile} moderationOpts={moderationOpts} />
<Handle profile={profile} /> <Handle profile={profile} />
</View> </View>
) )
}
}
function InlineNameAndHandle({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const verification = useSimpleVerificationState({profile})
const moderation = moderateProfile(profile, moderationOpts)
const name = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_row, a.align_end, a.flex_shrink]}>
<Text
emoji
style={[
a.font_bold,
a.leading_tight,
a.flex_shrink_0,
{maxWidth: '70%'},
]}
numberOfLines={1}>
{forceLTR(name)}
</Text>
{verification.showBadge && (
<View
style={[
a.pl_2xs,
a.self_center,
{marginTop: platform({default: 0, android: -1})},
]}>
<VerificationCheck
width={platform({android: 13, default: 12})}
verifier={verification.role === 'verifier'}
/>
</View>
)}
<Text
emoji
style={[
a.leading_tight,
t.atoms.text_contrast_medium,
{flexShrink: 10},
]}
numberOfLines={1}>
{NON_BREAKING_SPACE + handle}
</Text>
</View>
)
} }
export function Name({ export function Name({
+43 -45
View File
@@ -3,6 +3,7 @@ import {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -30,31 +31,33 @@ const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow
type TooltipContextType = { type TooltipContextType = {
position: 'top' | 'bottom' position: 'top' | 'bottom'
ready: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
} }
type TargetContextType = { type TargetMeasurements = {
targetMeasurements:
| {
x: number x: number
y: number y: number
width: number width: number
height: number height: number
} }
| undefined
targetRef: React.RefObject<View> type TargetContextType = {
targetMeasurements: TargetMeasurements | undefined
setTargetMeasurements: (measurements: TargetMeasurements) => void
shouldMeasure: boolean
} }
const TooltipContext = createContext<TooltipContextType>({ const TooltipContext = createContext<TooltipContextType>({
position: 'bottom', position: 'bottom',
ready: false, visible: false,
onVisibleChange: () => {}, onVisibleChange: () => {},
}) })
const TargetContext = createContext<TargetContextType>({ const TargetContext = createContext<TargetContextType>({
targetMeasurements: undefined, targetMeasurements: undefined,
targetRef: {current: null}, setTargetMeasurements: () => {},
shouldMeasure: false,
}) })
export function Outer({ export function Outer({
@@ -68,21 +71,12 @@ export function Outer({
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
/**
* Whether we have measured the target and are ready to show the tooltip.
*/
const [ready, setReady] = useState(false)
/** /**
* Lagging state to track the externally-controlled visibility of the * Lagging state to track the externally-controlled visibility of the
* tooltip. * tooltip, which needs to wait for the target to be measured before
* actually being shown.
*/ */
const [prevRequestVisible, setPrevRequestVisible] = useState< const [visible, setVisible] = useState<boolean>(false)
boolean | undefined
>()
/**
* Needs to reference the element this Tooltip is attached to.
*/
const targetRef = useRef<View>(null)
const [targetMeasurements, setTargetMeasurements] = useState< const [targetMeasurements, setTargetMeasurements] = useState<
| { | {
x: number x: number
@@ -93,33 +87,24 @@ export function Outer({
| undefined | undefined
>(undefined) >(undefined)
if (requestVisible && !prevRequestVisible) { if (requestVisible && !visible && targetMeasurements) {
setPrevRequestVisible(true) setVisible(true)
} else if (!requestVisible && visible) {
if (targetRef.current) { setVisible(false)
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
setReady(true)
}
})
}
} else if (!requestVisible && prevRequestVisible) {
setPrevRequestVisible(false)
setTargetMeasurements(undefined) setTargetMeasurements(undefined)
setReady(false)
} }
const ctx = useMemo( const ctx = useMemo(
() => ({position, ready, onVisibleChange}), () => ({position, visible, onVisibleChange}),
[position, ready, onVisibleChange], [position, visible, onVisibleChange],
) )
const targetCtx = useMemo( const targetCtx = useMemo(
() => ({targetMeasurements, targetRef}), () => ({
[targetMeasurements, targetRef], targetMeasurements,
setTargetMeasurements,
shouldMeasure: requestVisible,
}),
[requestVisible, targetMeasurements, setTargetMeasurements],
) )
return ( return (
@@ -132,7 +117,20 @@ export function Outer({
} }
export function Target({children}: {children: React.ReactNode}) { export function Target({children}: {children: React.ReactNode}) {
const {targetRef} = useContext(TargetContext) const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
const targetRef = useRef<View>(null)
useEffect(() => {
if (!shouldMeasure) return
/*
* Once opened, measure the dimensions and position of the target
*/
targetRef.current?.measure((_x, _y, width, height, pageX, pageY) => {
if (pageX !== undefined && pageY !== undefined && width && height) {
setTargetMeasurements({x: pageX, y: pageY, width, height})
}
})
}, [shouldMeasure, setTargetMeasurements])
return ( return (
<View collapsable={false} ref={targetRef}> <View collapsable={false} ref={targetRef}>
@@ -148,13 +146,13 @@ export function Content({
children: React.ReactNode children: React.ReactNode
label: string label: string
}) { }) {
const {position, ready, onVisibleChange} = useContext(TooltipContext) const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext) const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => { const requestClose = useCallback(() => {
onVisibleChange(false) onVisibleChange(false)
}, [onVisibleChange]) }, [onVisibleChange])
if (!ready || !targetMeasurements) return null if (!visible || !targetMeasurements) return null
return ( return (
<Portal> <Portal>
+8 -2
View File
@@ -13,10 +13,12 @@ import {Text} from '#/components/Typography'
type TooltipContextType = { type TooltipContextType = {
position: 'top' | 'bottom' position: 'top' | 'bottom'
onVisibleChange: (open: boolean) => void
} }
const TooltipContext = createContext<TooltipContextType>({ const TooltipContext = createContext<TooltipContextType>({
position: 'bottom', position: 'bottom',
onVisibleChange: () => {},
}) })
export function Outer({ export function Outer({
@@ -30,7 +32,10 @@ export function Outer({
visible: boolean visible: boolean
onVisibleChange: (visible: boolean) => void onVisibleChange: (visible: boolean) => void
}) { }) {
const ctx = useMemo(() => ({position}), [position]) const ctx = useMemo(
() => ({position, onVisibleChange}),
[position, onVisibleChange],
)
return ( return (
<Popover.Root open={visible} onOpenChange={onVisibleChange}> <Popover.Root open={visible} onOpenChange={onVisibleChange}>
<TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider> <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
@@ -54,7 +59,7 @@ export function Content({
label: string label: string
}) { }) {
const t = useTheme() const t = useTheme()
const {position} = useContext(TooltipContext) const {position, onVisibleChange} = useContext(TooltipContext)
return ( return (
<Popover.Portal> <Popover.Portal>
<Popover.Content <Popover.Content
@@ -63,6 +68,7 @@ export function Content({
side={position} side={position}
sideOffset={4} sideOffset={4}
collisionPadding={MIN_EDGE_SPACE} collisionPadding={MIN_EDGE_SPACE}
onInteractOutside={() => onVisibleChange(false)}
style={flatten([ style={flatten([
a.rounded_sm, a.rounded_sm,
select(t.name, { select(t.name, {
@@ -0,0 +1,89 @@
import {useCallback} from 'react'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useRequireEmailVerification} from '#/lib/hooks/useRequireEmailVerification'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {Button, ButtonIcon} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BellPlus_Stroke2_Corner0_Rounded as BellPlusIcon} from '#/components/icons/BellPlus'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
import type * as bsky from '#/types/bsky'
import {SubscribeProfileDialog} from './SubscribeProfileDialog'
export function SubscribeProfileButton({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const {_} = useLingui()
const requireEmailVerification = useRequireEmailVerification()
const subscribeDialogControl = useDialogControl()
const [activitySubscriptionsNudged, setActivitySubscriptionsNudged] =
useActivitySubscriptionsNudged()
const onDismissTooltip = () => {
setActivitySubscriptionsNudged(true)
}
const onPress = useCallback(() => {
subscribeDialogControl.open()
}, [subscribeDialogControl])
const name = createSanitizedDisplayName(profile, true)
const wrappedOnPress = requireEmailVerification(onPress, {
instructions: [
<Trans key="message">
Before you can get notifications for {name}'s posts, you must first
verify your email.
</Trans>,
],
})
const isSubscribed =
profile.viewer?.activitySubscription?.post ||
profile.viewer?.activitySubscription?.reply
const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
return (
<>
<Tooltip.Outer
visible={!activitySubscriptionsNudged}
onVisibleChange={onDismissTooltip}
position="bottom">
<Tooltip.Target>
<Button
accessibilityRole="button"
testID="dmBtn"
size="small"
color="secondary"
variant="solid"
shape="round"
label={_(msg`Get notified when ${name} posts`)}
onPress={wrappedOnPress}>
<ButtonIcon icon={Icon} size="md" />
</Button>
</Tooltip.Target>
<Tooltip.TextBubble>
<Text>
<Trans>Get notified about new posts</Trans>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer>
<SubscribeProfileDialog
control={subscribeDialogControl}
profile={profile}
moderationOpts={moderationOpts}
/>
</>
)
}
@@ -0,0 +1,309 @@
import {useMemo, useState} from 'react'
import {View} from 'react-native'
import {
type AppBskyNotificationDefs,
type AppBskyNotificationListActivitySubscriptions,
type ModerationOpts,
type Un$Typed,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type InfiniteData,
useMutation,
useQueryClient,
} from '@tanstack/react-query'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {cleanError} from '#/lib/strings/errors'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {updateProfileShadow} from '#/state/cache/profile-shadow'
import {RQKEY_getActivitySubscriptions} from '#/state/queries/activity-subscriptions'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {platform, useTheme, web} from '#/alf'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {
Button,
ButtonIcon,
type ButtonProps,
ButtonText,
} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
export function SubscribeProfileDialog({
control,
profile,
moderationOpts,
includeProfile,
}: {
control: Dialog.DialogControlProps
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<DialogInner
profile={profile}
moderationOpts={moderationOpts}
includeProfile={includeProfile}
/>
</Dialog.Outer>
)
}
function DialogInner({
profile,
moderationOpts,
includeProfile,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
includeProfile?: boolean
}) {
const {_} = useLingui()
const t = useTheme()
const agent = useAgent()
const control = Dialog.useDialogContext()
const queryClient = useQueryClient()
const initialState = parseActivitySubscription(
profile.viewer?.activitySubscription,
)
const [state, setState] = useState(initialState)
const values = useMemo(() => {
const {post, reply} = state
const res = []
if (post) res.push('post')
if (reply) res.push('reply')
return res
}, [state])
const onChange = (newValues: string[]) => {
setState(oldValues => {
// ensure you can't have reply without post
if (!oldValues.reply && newValues.includes('reply')) {
return {
post: true,
reply: true,
}
}
if (oldValues.post && !newValues.includes('post')) {
return {
post: false,
reply: false,
}
}
return {
post: newValues.includes('post'),
reply: newValues.includes('reply'),
}
})
}
const {
mutate: saveChanges,
isPending: isSaving,
error,
} = useMutation({
mutationFn: async (
activitySubscription: Un$Typed<AppBskyNotificationDefs.ActivitySubscription>,
) => {
await agent.app.bsky.notification.putActivitySubscription({
subject: profile.did,
activitySubscription,
})
},
onSuccess: (_data, activitySubscription) => {
control.close(() => {
updateProfileShadow(queryClient, profile.did, {
activitySubscription,
})
if (!activitySubscription.post && !activitySubscription.reply) {
logger.metric('activitySubscription:disable', {})
Toast.show(
_(
msg`You will no longer receive notifications for ${sanitizeHandle(profile.handle, '@')}`,
),
'check',
)
// filter out the subscription
queryClient.setQueryData(
RQKEY_getActivitySubscriptions,
(
old?: InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>,
) => {
if (!old) return old
return {
...old,
pages: old.pages.map(page => ({
...page,
subscriptions: page.subscriptions.filter(
item => item.did !== profile.did,
),
})),
}
},
)
} else {
logger.metric('activitySubscription:enable', {
setting: activitySubscription.reply ? 'posts_and_replies' : 'posts',
})
if (!initialState.post && !initialState.reply) {
Toast.show(
_(
msg`You'll start receiving notifications for ${sanitizeHandle(profile.handle, '@')}!`,
),
'check',
)
} else {
Toast.show(_(msg`Changes saved`), 'check')
}
}
})
},
onError: err => {
logger.error('Could not save activity subscription', {message: err})
},
})
const buttonProps: Omit<ButtonProps, 'children'> = useMemo(() => {
const isDirty =
state.post !== initialState.post || state.reply !== initialState.reply
const hasAny = state.post || state.reply
if (isDirty) {
return {
label: _(msg`Save changes`),
color: hasAny ? 'primary' : 'negative',
onPress: () => saveChanges(state),
disabled: isSaving,
}
} else {
// on web, a disabled save button feels more natural than a massive close button
if (isWeb) {
return {
label: _(msg`Save changes`),
color: 'secondary',
disabled: true,
}
} else {
return {
label: _(msg`Cancel`),
color: 'secondary',
onPress: () => control.close(),
}
}
}
}, [state, initialState, control, _, isSaving, saveChanges])
const name = createSanitizedDisplayName(profile, false)
return (
<Dialog.ScrollableInner
style={web({maxWidth: 400})}
label={_(msg`Get notified of new posts from ${name}`)}>
<View style={[a.gap_lg]}>
<View style={[a.gap_xs]}>
<Text style={[a.font_heavy, a.text_2xl]}>
<Trans>Keep me posted</Trans>
</Text>
<Text style={[t.atoms.text_contrast_medium, a.text_md]}>
<Trans>Get notified of this accounts activity</Trans>
</Text>
</View>
{includeProfile && (
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
)}
<Toggle.Group
label={_(msg`Subscribe to account activity`)}
values={values}
onChange={onChange}>
<View style={[a.gap_sm]}>
<Toggle.Item
label={_(msg`Posts`)}
name="post"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Posts</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
<Toggle.Item
label={_(msg`Replies`)}
name="reply"
style={[
a.flex_1,
a.py_xs,
platform({
native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_sm],
}),
]}>
<Toggle.LabelText
style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
<Trans>Replies</Trans>
</Toggle.LabelText>
<Toggle.Switch />
</Toggle.Item>
</View>
</Toggle.Group>
{error && (
<Admonition type="error">
<Trans>Could not save changes: {cleanError(error)}</Trans>
</Admonition>
)}
<Button {...buttonProps} size="large" variant="solid">
<ButtonText>{buttonProps.label}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} />}
</Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function parseActivitySubscription(
sub?: AppBskyNotificationDefs.ActivitySubscription,
): Un$Typed<AppBskyNotificationDefs.ActivitySubscription> {
if (!sub) return {post: false, reply: false}
const {post, reply} = sub
return {post, reply}
}
@@ -0,0 +1,177 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isWeb} from '#/platform/detection'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useNuxDialogContext} from '#/components/dialogs/nuxs'
import {Sparkle_Stroke2_Corner0_Rounded as SparkleIcon} from '#/components/icons/Sparkle'
import {Text} from '#/components/Typography'
export function ActivitySubscriptionsNUX() {
const t = useTheme()
const {_} = useLingui()
const nuxDialogs = useNuxDialogContext()
const control = Dialog.useDialogControl()
Dialog.useAutoOpen(control)
const onClose = useCallback(() => {
nuxDialogs.dismissActiveNux()
}, [nuxDialogs])
return (
<Dialog.Outer control={control} onClose={onClose}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Introducing activity notifications`)}
style={[web({maxWidth: 400})]}
contentContainerStyle={[
{
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
},
]}>
<View
style={[
a.align_center,
a.overflow_hidden,
t.atoms.bg_contrast_25,
{
gap: isWeb ? 16 : 24,
paddingTop: isWeb ? 24 : 48,
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
},
]}>
<View
style={[
a.pl_sm,
a.pr_md,
a.py_sm,
a.rounded_full,
a.flex_row,
a.align_center,
a.gap_xs,
{
backgroundColor: t.palette.primary_100,
},
]}>
<SparkleIcon fill={t.palette.primary_800} size="sm" />
<Text
style={[
a.font_bold,
{
color: t.palette.primary_800,
},
]}>
<Trans>New Feature</Trans>
</Text>
</View>
<View style={[a.relative, a.w_full]}>
<View
style={[
a.absolute,
t.atoms.bg_contrast_25,
t.atoms.shadow_md,
{
shadowOpacity: 0.4,
top: 5,
bottom: 0,
left: '17%',
right: '17%',
width: '66%',
borderTopLeftRadius: 40,
borderTopRightRadius: 40,
},
]}
/>
<View
style={[
a.overflow_hidden,
{
aspectRatio: 398 / 228,
},
]}>
<Image
accessibilityIgnoresInvertColors
source={require('../../../../assets/images/activity_notifications_announcement.webp')}
style={[
a.w_full,
{
aspectRatio: 398 / 268,
},
]}
alt={_(
msg`A screenshot of a profile page with a bell icon next to the follow button, indicating the new activity notifications feature.`,
)}
/>
</View>
</View>
</View>
<View
style={[
a.align_center,
a.px_xl,
isWeb ? [a.pt_xl, a.gap_xl, a.pb_sm] : [a.pt_3xl, a.gap_3xl],
]}>
<View style={[a.gap_md, a.align_center]}>
<Text
style={[
a.text_3xl,
a.leading_tight,
a.font_heavy,
a.text_center,
{
fontSize: isWeb ? 28 : 32,
maxWidth: 300,
},
]}>
<Trans>Get notified when someone posts</Trans>
</Text>
<Text
style={[
a.text_md,
a.leading_snug,
a.text_center,
{
maxWidth: 340,
},
]}>
<Trans>
You can now choose to be notified when specific people post. If
theres someone you want timely updates from, go to their
profile and find the new bell icon near the follow button.
</Trans>
</Text>
</View>
{!isWeb && (
<Button
label={_(msg`Close`)}
size="large"
variant="solid"
color="primary"
onPress={() => {
control.close()
}}
style={[a.w_full, {maxWidth: 280}]}>
<ButtonText>
<Trans>Close</Trans>
</ButtonText>
</Button>
)}
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
+10 -9
View File
@@ -11,12 +11,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session' import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell' import {useOnboardingState} from '#/state/shell'
import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement' import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
/* /*
* NUXs * NUXs
*/ */
import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing' import {isSnoozed, snooze, unsnooze} from '#/components/dialogs/nuxs/snoozing'
import {isDaysOld} from '#/components/dialogs/nuxs/utils' import {isExistingUserAsOf} from '#/components/dialogs/nuxs/utils'
type Context = { type Context = {
activeNux: Nux | undefined activeNux: Nux | undefined
@@ -33,9 +33,12 @@ const queuedNuxs: {
}) => boolean }) => boolean
}[] = [ }[] = [
{ {
id: Nux.InitialVerificationAnnouncement, id: Nux.ActivitySubscriptions,
enabled: ({currentProfile}) => { enabled: ({currentProfile}) => {
return isDaysOld(2, currentProfile.createdAt) return isExistingUserAsOf(
'2025-07-03T00:00:00.000Z',
currentProfile.createdAt,
)
}, },
}, },
] ]
@@ -111,7 +114,7 @@ function Inner({
} }
React.useEffect(() => { React.useEffect(() => {
if (snoozed) return if (snoozed) return // comment this out to test
if (!nuxs) return if (!nuxs) return
for (const {id, enabled} of queuedNuxs) { for (const {id, enabled} of queuedNuxs) {
@@ -119,7 +122,7 @@ function Inner({
// check if completed first // check if completed first
if (nux && nux.completed) { if (nux && nux.completed) {
continue continue // comment this out to test
} }
// then check gate (track exposure) // then check gate (track exposure)
@@ -172,9 +175,7 @@ function Inner({
return ( return (
<Context.Provider value={ctx}> <Context.Provider value={ctx}>
{/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/} {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
{activeNux === Nux.InitialVerificationAnnouncement && ( {activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
<InitialVerificationAnnouncement />
)}
</Context.Provider> </Context.Provider>
) )
} }
+15
View File
@@ -16,3 +16,18 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true if (isOldEnough) return true
return false return false
} }
export function isExistingUserAsOf(date: string, createdAt?: string) {
/*
* Should never happen because we gate NUXs to only accounts with a valid
* profile and a `createdAt` (see `nuxs/index.tsx`). But if it ever did, the
* account is either old enough to be pre-onboarding, or some failure happened
* during account creation. Fail closed. - esb
*/
if (!createdAt) return false
const threshold = Date.parse(date)
const then = new Date(createdAt).getTime()
return then < threshold
}
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const BellPlus_Stroke2_Corner0_Rounded = createSinglePathSVG({
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',
})
+4
View File
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({ export const BellRinging_Stroke2_Corner0_Rounded = createSinglePathSVG({
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', 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',
}) })
export const BellRinging_Filled_Corner0_Rounded = createSinglePathSVG({
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',
})
+6 -6
View File
@@ -1,20 +1,20 @@
import { import {
AppBskyFeedDefs, type Agent,
AppBskyFeedGetListFeed as GetListFeed, type AppBskyFeedDefs,
BskyAgent, type AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api' } from '@atproto/api'
import {FeedAPI, FeedAPIResponse} from './types' import {type FeedAPI, type FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI { export class ListFeedAPI implements FeedAPI {
agent: BskyAgent agent: Agent
params: GetListFeed.QueryParams params: GetListFeed.QueryParams
constructor({ constructor({
agent, agent,
feedParams, feedParams,
}: { }: {
agent: BskyAgent agent: Agent
feedParams: GetListFeed.QueryParams feedParams: GetListFeed.QueryParams
}) { }) {
this.agent = agent this.agent = agent
+52
View File
@@ -0,0 +1,52 @@
import {
type Agent,
type AppBskyFeedDefs,
type AppBskyFeedGetPosts,
} from '@atproto/api'
import {logger} from '#/logger'
import {type FeedAPI, type FeedAPIResponse} from './types'
export class PostListFeedAPI implements FeedAPI {
agent: Agent
params: AppBskyFeedGetPosts.QueryParams
peek: AppBskyFeedDefs.FeedViewPost | null = null
constructor({
agent,
feedParams,
}: {
agent: Agent
feedParams: AppBskyFeedGetPosts.QueryParams
}) {
this.agent = agent
if (feedParams.uris.length > 25) {
logger.warn(
`Too many URIs provided - expected 25, got ${feedParams.uris.length}`,
)
}
this.params = {
uris: feedParams.uris.slice(0, 25),
}
}
async peekLatest(): Promise<AppBskyFeedDefs.FeedViewPost> {
if (this.peek) return this.peek
throw new Error('Has not fetched yet')
}
async fetch({}: {}): Promise<FeedAPIResponse> {
const res = await this.agent.app.bsky.feed.getPosts({
...this.params,
})
if (res.success) {
this.peek = {post: res.data.posts[0]}
return {
feed: res.data.posts.map(post => ({post})),
}
}
return {
feed: [],
}
}
}
+35 -9
View File
@@ -1,6 +1,6 @@
import {useEffect} from 'react' import {useEffect} from 'react'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'
import {type AppBskyNotificationListNotifications} from '@atproto/api' import {AtUri} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native' import {CommonActions, useNavigation} from '@react-navigation/native'
@@ -32,6 +32,7 @@ export type NotificationReason =
| 'repost-via-repost' | 'repost-via-repost'
| 'verified' | 'verified'
| 'unverified' | 'unverified'
| 'subscribed-post'
/** /**
* Manually overridden type, but retains the possibility of * Manually overridden type, but retains the possibility of
@@ -112,61 +113,68 @@ export function useNotificationsHandler() {
}) })
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'like' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'like' satisfies NotificationReason,
{ {
name: _(msg`Likes`), name: _(msg`Likes`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts`), name: _(msg`Reposts`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'reply' satisfies NotificationReason,
{ {
name: _(msg`Replies`), name: _(msg`Replies`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'mention' satisfies NotificationReason,
{ {
name: _(msg`Mentions`), name: _(msg`Mentions`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'quote' satisfies NotificationReason,
{ {
name: _(msg`Quotes`), name: _(msg`Quotes`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'follow' satisfies NotificationReason,
{ {
name: _(msg`New followers`), name: _(msg`New followers`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'like-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Likes of your reposts`), name: _(msg`Likes of your reposts`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync( Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'], 'repost-via-repost' satisfies NotificationReason,
{ {
name: _(msg`Reposts of your reposts`), name: _(msg`Reposts of your reposts`),
importance: Notifications.AndroidImportance.HIGH, importance: Notifications.AndroidImportance.HIGH,
}, },
) )
Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason,
{
name: _(msg`Activity from others`),
importance: Notifications.AndroidImportance.HIGH,
},
)
}, [_]) }, [_])
useEffect(() => { useEffect(() => {
@@ -220,6 +228,23 @@ export function useNotificationsHandler() {
} }
} else { } else {
switch (payload.reason) { switch (payload.reason) {
case 'subscribed-post':
const urip = new AtUri(payload.uri)
if (urip.collection === 'app.bsky.feed.post') {
setTimeout(() => {
// @ts-expect-error types are weird here
navigation.navigate('HomeTab', {
screen: 'PostThread',
params: {
name: urip.host,
rkey: urip.rkey,
},
})
}, 500)
} else {
resetToTab('NotificationsTab')
}
break
case 'like': case 'like':
case 'repost': case 'repost':
case 'follow': case 'follow':
@@ -231,6 +256,7 @@ export function useNotificationsHandler() {
case 'repost-via-repost': case 'repost-via-repost':
case 'verified': case 'verified':
case 'unverified': case 'unverified':
default:
resetToTab('NotificationsTab') resetToTab('NotificationsTab')
break break
// TODO implement these after we have an idea of how to handle each individual case // TODO implement these after we have an idea of how to handle each individual case
@@ -1,12 +1,9 @@
import {AppBskyActorDefs} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import type * as bsky from '#/types/bsky'
export function createSanitizedDisplayName( export function createSanitizedDisplayName(
profile: profile: bsky.profile.AnyProfileView,
| AppBskyActorDefs.ProfileViewBasic
| AppBskyActorDefs.ProfileViewDetailed,
noAt = false, noAt = false,
) { ) {
if (profile.displayName != null && profile.displayName !== '') { if (profile.displayName != null && profile.displayName !== '') {
+2
View File
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
AppearanceSettings: undefined AppearanceSettings: undefined
AccountSettings: undefined AccountSettings: undefined
PrivacyAndSecuritySettings: undefined PrivacyAndSecuritySettings: undefined
ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined ContentAndMediaSettings: undefined
NotificationSettings: undefined NotificationSettings: undefined
ReplyNotificationSettings: undefined ReplyNotificationSettings: undefined
@@ -72,6 +73,7 @@ export type CommonNavigatorParams = {
MessagesConversation: {conversation: string; embed?: string; accept?: true} MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesSettings: undefined MessagesSettings: undefined
MessagesInbox: undefined MessagesInbox: undefined
NotificationsActivityList: {posts: string}
LegacyNotificationSettings: undefined LegacyNotificationSettings: undefined
Feeds: undefined Feeds: undefined
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
-1
View File
@@ -7,7 +7,6 @@ export type Gate =
| 'old_postonboarding' | 'old_postonboarding'
| 'onboarding_add_video_feed' | 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced' | 'post_threads_v2_unspecced'
| 'reengagement_features'
| 'remove_show_latest_button' | 'remove_show_latest_button'
| 'test_gate_1' | 'test_gate_1'
| 'test_gate_2' | 'test_gate_2'
+13
View File
@@ -443,4 +443,17 @@ export type MetricEvents = {
[key: string]: any [key: string]: any
} }
'thread:click:headerMenuOpen': {} 'thread:click:headerMenuOpen': {}
'activitySubscription:enable': {
setting: 'posts' | 'posts_and_replies'
}
'activitySubscription:disable': {}
'activityPreference:changeChannels': {
name: string
push: boolean
list: boolean
}
'activityPreference:changeFilter': {
name: string
value: string
}
} }
+2
View File
@@ -11,6 +11,7 @@ export const router = new Router<AllNavigatableRoutes>({
Search: '/search', Search: '/search',
Feeds: '/feeds', Feeds: '/feeds',
Notifications: '/notifications', Notifications: '/notifications',
NotificationsActivityList: '/notifications/activity',
LegacyNotificationSettings: '/notifications/settings', LegacyNotificationSettings: '/notifications/settings',
Settings: '/settings', Settings: '/settings',
Lists: '/lists', Lists: '/lists',
@@ -50,6 +51,7 @@ export const router = new Router<AllNavigatableRoutes>({
SavedFeeds: '/settings/saved-feeds', SavedFeeds: '/settings/saved-feeds',
AccountSettings: '/settings/account', AccountSettings: '/settings/account',
PrivacyAndSecuritySettings: '/settings/privacy-and-security', PrivacyAndSecuritySettings: '/settings/privacy-and-security',
ActivityPrivacySettings: '/settings/privacy-and-security/activity',
ContentAndMediaSettings: '/settings/content-and-media', ContentAndMediaSettings: '/settings/content-and-media',
InterestsSettings: '/settings/interests', InterestsSettings: '/settings/interests',
AboutSettings: '/settings/about', AboutSettings: '/settings/about',
@@ -0,0 +1,44 @@
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type AllNavigatorParams} from '#/lib/routes/types'
import {PostFeed} from '#/view/com/posts/PostFeed'
import {EmptyState} from '#/view/com/util/EmptyState'
import * as Layout from '#/components/Layout'
import {ListFooter} from '#/components/Lists'
type Props = NativeStackScreenProps<
AllNavigatorParams,
'NotificationsActivityList'
>
export function NotificationsActivityListScreen({
route: {
params: {posts},
},
}: Props) {
const uris = decodeURIComponent(posts)
const {_} = useLingui()
return (
<Layout.Screen testID="NotificationsActivityListScreen">
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Notifications</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<PostFeed
feed={`posts|${uris}`}
disablePoll
renderEmptyState={() => (
<EmptyState icon="growth" message={_(msg`No posts here`)} />
)}
renderEndOfFeed={() => <ListFooter />}
/>
</Layout.Screen>
)
}
@@ -15,7 +15,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow} from '#/state/cache/types'
import { import {
useProfileBlockMutationQueue, useProfileBlockMutationQueue,
useProfileFollowMutationQueue, useProfileFollowMutationQueue,
@@ -24,6 +23,7 @@ import {useRequireAuth, useSession} from '#/state/session'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu' import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf' import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton' import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
@@ -58,8 +58,8 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => { }: Props): React.ReactNode => {
const t = useTheme() const t = useTheme()
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const profile: Shadow<AppBskyActorDefs.ProfileViewDetailed> = const profile =
useProfileShadow(profileUnshadowed) useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(profileUnshadowed)
const {currentAccount, hasSession} = useSession() const {currentAccount, hasSession} = useSession()
const {_} = useLingui() const {_} = useLingui()
const moderation = useMemo( const moderation = useMemo(
@@ -134,13 +134,26 @@ let ProfileHeaderStandard = ({
} }
}, [_, queueUnblock]) }, [_, queueUnblock])
const isMe = React.useMemo( const isMe = useMemo(
() => currentAccount?.did === profile.did, () => currentAccount?.did === profile.did,
[currentAccount, profile], [currentAccount, profile],
) )
const {isActive: live} = useActorStatus(profile) const {isActive: live} = useActorStatus(profile)
const subscriptionsAllowed = useMemo(() => {
switch (profile.associated?.activitySubscription?.allowSubscriptions) {
case 'followers':
case undefined:
return !!profile.viewer?.following
case 'mutuals':
return !!profile.viewer?.following && !!profile.viewer.followedBy
case 'none':
default:
return false
}
}, [profile])
return ( return (
<ProfileHeaderShell <ProfileHeaderShell
profile={profile} profile={profile}
@@ -198,6 +211,12 @@ let ProfileHeaderStandard = ({
) )
) : !profile.viewer?.blockedBy ? ( ) : !profile.viewer?.blockedBy ? (
<> <>
{hasSession && subscriptionsAllowed && (
<SubscribeProfileButton
profile={profile}
moderationOpts={moderationOpts}
/>
)}
{hasSession && <MessageProfileButton profile={profile} />} {hasSession && <MessageProfileButton profile={profile} />}
<Button <Button
+2 -17
View File
@@ -1,8 +1,8 @@
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import { import {
useHapticsDisabled, useHapticsDisabled,
@@ -16,12 +16,10 @@ import {
} from '#/state/preferences/large-alt-badge' } from '#/state/preferences/large-alt-badge'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility' import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
import * as Layout from '#/components/Layout' import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
type Props = NativeStackScreenProps< type Props = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -100,19 +98,6 @@ export function AccessibilitySettingsScreen({}: Props) {
</SettingsList.Group> </SettingsList.Group>
</> </>
)} )}
<SettingsList.Item>
<Admonition type="info" style={[a.flex_1]}>
<Trans>
Autoplay options have moved to the{' '}
<InlineLinkText
to="/settings/content-and-media"
label={_(msg`Content and media`)}>
Content and Media settings
</InlineLinkText>
.
</Trans>
</Admonition>
</SettingsList.Item>
</SettingsList.Container> </SettingsList.Container>
</Layout.Content> </Layout.Content>
</Layout.Screen> </Layout.Screen>
@@ -0,0 +1,140 @@
import {View} from 'react-native'
import {type AppBskyNotificationDeclaration} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {
useNotificationDeclarationMutation,
useNotificationDeclarationQuery,
} from '#/state/queries/activity-subscriptions'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Toggle from '#/components/forms/Toggle'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
import * as SettingsList from './components/SettingsList'
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
type Props = NativeStackScreenProps<
AllNavigatorParams,
'ActivityPrivacySettings'
>
export function ActivityPrivacySettingsScreen({}: Props) {
const {
data: notificationDeclaration,
isPending,
isError,
} = useNotificationDeclarationQuery()
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Privacy and Security</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<SettingsList.Container>
<SettingsList.Item style={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
bold
titleText={
<Trans>Allow others to be notified of your posts</Trans>
}
subtitleText={
<Trans>
This feature allows users to receive notifications for your
new posts and replies. Who do you want to enable this for?
</Trans>
}
/>
</SettingsList.Item>
<View style={[a.px_xl, a.pt_md]}>
{isError ? (
<Admonition type="error">
<Trans>Failed to load preference.</Trans>
</Admonition>
) : isPending ? (
<View style={[a.w_full, a.pt_5xl, a.align_center]}>
<Loader size="xl" />
</View>
) : (
<Inner notificationDeclaration={notificationDeclaration} />
)}
</View>
</SettingsList.Container>
</Layout.Content>
</Layout.Screen>
)
}
export function Inner({
notificationDeclaration,
}: {
notificationDeclaration: {
uri?: string
cid?: string
value: AppBskyNotificationDeclaration.Record
}
}) {
const t = useTheme()
const {_} = useLingui()
const {mutate} = useNotificationDeclarationMutation()
const onChangeFilter = ([declaration]: string[]) => {
mutate({
$type: 'app.bsky.notification.declaration',
allowSubscriptions: declaration,
})
}
return (
<Toggle.Group
type="radio"
label={_(
msg`Filter who can opt to receive notifications for your activity`,
)}
values={[notificationDeclaration.value.allowSubscriptions]}
onChange={onChangeFilter}>
<View style={[a.gap_sm]}>
<Toggle.Item
label={_(msg`Anyone who follows me`)}
name="followers"
style={[a.flex_row, a.py_xs, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
<Trans>Anyone who follows me</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
label={_(msg`Only followers who I follow`)}
name="mutuals"
style={[a.flex_row, a.py_xs, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
<Trans>Only followers who I follow</Trans>
</Toggle.LabelText>
</Toggle.Item>
<Toggle.Item
label={_(msg`No one`)}
name="none"
style={[a.flex_row, a.py_xs, a.gap_sm]}>
<Toggle.Radio />
<Toggle.LabelText style={[t.atoms.text, a.font_normal, a.text_md]}>
<Trans>No one</Trans>
</Toggle.LabelText>
</Toggle.Item>
</View>
</Toggle.Group>
)
}
+3 -3
View File
@@ -7,12 +7,12 @@ import Animated, {
LinearTransition, LinearTransition,
StretchOutY, StretchOutY,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import {ComAtprotoServerListAppPasswords} from '@atproto/api' import {type ComAtprotoServerListAppPasswords} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors' import {cleanError} from '#/lib/strings/errors'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import { import {
+6 -3
View File
@@ -1,4 +1,4 @@
import React, {useCallback} from 'react' import {useCallback} from 'react'
import Animated, { import Animated, {
FadeInUp, FadeInUp,
FadeOutUp, FadeOutUp,
@@ -9,14 +9,17 @@ import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {IS_INTERNAL} from '#/lib/app-info' import {IS_INTERNAL} from '#/lib/app-info'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig' import {useGate} from '#/lib/statsig/statsig'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useSetThemePrefs, useThemePrefs} from '#/state/shell' import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem' import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
import {atoms as a, native, useAlf, useTheme} from '#/alf' import {atoms as a, native, useAlf, useTheme} from '#/alf'
import * as ToggleButton from '#/components/forms/ToggleButton' import * as ToggleButton from '#/components/forms/ToggleButton'
import {Props as SVGIconProps} from '#/components/icons/common' import {type Props as SVGIconProps} from '#/components/icons/common'
import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon' import {Moon_Stroke2_Corner0_Rounded as MoonIcon} from '#/components/icons/Moon'
import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone' import {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize' import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
@@ -2,9 +2,12 @@ import {Fragment} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {Trans} from '@lingui/macro' import {Trans} from '@lingui/macro'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import { import {
EmbedPlayerSource, type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {
type EmbedPlayerSource,
externalEmbedLabels, externalEmbedLabels,
} from '#/lib/strings/embed-player' } from '#/lib/strings/embed-player'
import { import {
@@ -1,7 +1,10 @@
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' import {
type CommonNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import { import {
usePreferencesQuery, usePreferencesQuery,
useSetFeedViewPreferencesMutation, useSetFeedViewPreferencesMutation,
@@ -0,0 +1,263 @@
import {useCallback, useMemo} from 'react'
import {type ListRenderItemInfo, Text as RNText, View} from 'react-native'
import {type ModerationOpts} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActivitySubscriptionsQuery} from '#/state/queries/activity-subscriptions'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {List} from '#/view/com/util/List'
import {atoms as a, useTheme} from '#/alf'
import {SubscribeProfileDialog} from '#/components/activity-notifications/SubscribeProfileDialog'
import * as Admonition from '#/components/Admonition'
import {Button, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {BellRinging_Filled_Corner0_Rounded as BellRingingFilledIcon} from '#/components/icons/BellRinging'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link'
import {ListFooter} from '#/components/Lists'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import {Text} from '#/components/Typography'
import type * as bsky from '#/types/bsky'
import * as SettingsList from '../components/SettingsList'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {PreferenceControls} from './components/PreferenceControls'
type Props = NativeStackScreenProps<
AllNavigatorParams,
'ActivityNotificationSettings'
>
export function ActivityNotificationSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery()
const moderationOpts = useModerationOpts()
const {
data: subscriptions,
isPending,
error,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
} = useActivitySubscriptionsQuery()
const items = useMemo(() => {
if (!subscriptions) return []
return subscriptions?.pages.flatMap(page => page.subscriptions)
}, [subscriptions])
const renderItem = useCallback(
({item}: ListRenderItemInfo<bsky.profile.AnyProfileView>) => {
if (!moderationOpts) return null
return (
<ActivitySubscriptionCard
profile={item}
moderationOpts={moderationOpts}
/>
)
},
[moderationOpts],
)
const onEndReached = useCallback(async () => {
if (isFetchingNextPage || !hasNextPage || isError) return
try {
await fetchNextPage()
} catch (err) {
logger.error('Failed to load more likes', {message: err})
}
}, [isFetchingNextPage, hasNextPage, isError, fetchNextPage])
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Notifications</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<List
ListHeaderComponent={
<SettingsList.Container>
<SettingsList.Item style={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
bold
titleText={<Trans>Activity from others</Trans>}
subtitleText={
<Trans>
Get notified about posts and replies from accounts you
choose.
</Trans>
}
/>
</SettingsList.Item>
{isError ? (
<View style={[a.px_lg, a.pt_md]}>
<Admonition.Admonition type="error">
<Trans>Failed to load notification settings.</Trans>
</Admonition.Admonition>
</View>
) : (
<PreferenceControls
name="subscribedPost"
preference={preferences?.subscribedPost}
/>
)}
</SettingsList.Container>
}
data={items}
keyExtractor={keyExtractor}
renderItem={renderItem}
onEndReached={onEndReached}
onEndReachedThreshold={4}
ListEmptyComponent={
error ? null : (
<View style={[a.px_xl, a.py_md]}>
{!isPending ? (
<Admonition.Outer type="tip">
<Admonition.Row>
<Admonition.Icon />
<View style={[a.flex_1, a.gap_sm]}>
<Admonition.Text>
<Trans>
Enable notifications for an account by visiting their
profile and pressing the{' '}
<RNText
style={[a.font_bold, t.atoms.text_contrast_high]}>
bell icon
</RNText>{' '}
<BellRingingFilledIcon
size="xs"
style={t.atoms.text_contrast_high}
/>
.
</Trans>
</Admonition.Text>
<Admonition.Text>
<Trans>
If you want to restrict who can receive notifications
for your account's activity, you can change this in{' '}
<InlineLinkText
label={_(msg`Privacy and Security settings`)}
to={{screen: 'ActivityPrivacySettings'}}
style={[a.font_bold]}>
Settings &rarr; Privacy and Security
</InlineLinkText>
.
</Trans>
</Admonition.Text>
</View>
</Admonition.Row>
</Admonition.Outer>
) : (
<View style={[a.flex_1, a.align_center, a.pt_xl]}>
<Loader size="lg" />
</View>
)}
</View>
)
}
ListFooterComponent={
<ListFooter
style={[items.length === 0 && a.border_transparent]}
isFetchingNextPage={isFetchingNextPage}
error={cleanError(error)}
onRetry={fetchNextPage}
hasNextPage={hasNextPage}
/>
}
windowSize={11}
/>
</Layout.Screen>
)
}
function keyExtractor(item: bsky.profile.AnyProfileView) {
return item.did
}
function ActivitySubscriptionCard({
profile: profileUnshadowed,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const profile = useProfileShadow(profileUnshadowed)
const control = useDialogControl()
const {_} = useLingui()
const t = useTheme()
const preview = useMemo(() => {
const actSub = profile.viewer?.activitySubscription
if (actSub?.post && actSub?.reply) {
return _(msg`Posts, Replies`)
} else if (actSub?.post) {
return _(msg`Posts`)
} else if (actSub?.reply) {
return _(msg`Replies`)
}
return _(msg`None`)
}, [_, profile.viewer?.activitySubscription])
return (
<View style={[a.py_md, a.px_xl, a.border_t, t.atoms.border_contrast_low]}>
<ProfileCard.Outer>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
/>
<View style={[a.flex_1, a.gap_2xs]}>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
inline
/>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{preview}
</Text>
</View>
<Button
label={_(
msg`Edit notifications from ${createSanitizedDisplayName(
profile,
)}`,
)}
size="small"
color="primary"
variant="solid"
onPress={control.open}>
<ButtonText>
<Trans>Edit</Trans>
</ButtonText>
</Button>
</ProfileCard.Header>
</ProfileCard.Outer>
<SubscribeProfileDialog
control={control}
profile={profile}
moderationOpts={moderationOpts}
includeProfile
/>
</View>
)
}
@@ -5,7 +5,7 @@ import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger'
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings' import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
import {atoms as a, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import * as Toggle from '#/components/forms/Toggle' import * as Toggle from '#/components/forms/Toggle'
@@ -28,10 +28,6 @@ export function PreferenceControls({
preference?: AppBskyNotificationDefs.Preference | FilterablePreference preference?: AppBskyNotificationDefs.Preference | FilterablePreference
allowDisableInApp?: boolean allowDisableInApp?: boolean
}) { }) {
const gate = useGate()
if (!gate('reengagement_features')) return null
if (!preference) if (!preference)
return ( return (
<View style={[a.w_full, a.pt_5xl, a.align_center]}> <View style={[a.w_full, a.pt_5xl, a.align_center]}>
@@ -78,6 +74,12 @@ export function Inner({
push: change.includes('push'), push: change.includes('push'),
} satisfies typeof preference } satisfies typeof preference
logger.metric('activityPreference:changeChannels', {
name,
push: newPreference.push,
list: newPreference.list,
})
mutate({ mutate({
[name]: newPreference, [name]: newPreference,
...Object.fromEntries(syncOthers.map(key => [key, newPreference])), ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
@@ -93,6 +95,8 @@ export function Inner({
include: change, include: change,
} satisfies typeof preference } satisfies typeof preference
logger.metric('activityPreference:changeFilter', {name, value: change})
mutate({ mutate({
[name]: newPreference, [name]: newPreference,
...Object.fromEntries(syncOthers.map(key => [key, newPreference])), ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
@@ -114,7 +118,7 @@ export function Inner({
a.py_xs, a.py_xs,
platform({ platform({
native: [a.justify_between], native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_md], web: [a.flex_row_reverse, a.gap_sm],
}), }),
]}> ]}>
<Toggle.LabelText <Toggle.LabelText
@@ -131,7 +135,7 @@ export function Inner({
a.py_xs, a.py_xs,
platform({ platform({
native: [a.justify_between], native: [a.justify_between],
web: [a.flex_row_reverse, a.gap_md], web: [a.flex_row_reverse, a.gap_sm],
}), }),
]}> ]}>
<Toggle.LabelText <Toggle.LabelText
@@ -159,11 +163,7 @@ export function Inner({
<Toggle.Item <Toggle.Item
label={_(msg`Everyone`)} label={_(msg`Everyone`)}
name="all" name="all"
style={[ style={[a.flex_row, a.py_xs, a.gap_sm]}>
a.flex_row,
a.py_xs,
platform({native: [a.gap_sm], web: [a.gap_md]}),
]}>
<Toggle.Radio /> <Toggle.Radio />
<Toggle.LabelText <Toggle.LabelText
style={[ style={[
@@ -177,11 +177,7 @@ export function Inner({
<Toggle.Item <Toggle.Item
label={_(msg`People I follow`)} label={_(msg`People I follow`)}
name="follows" name="follows"
style={[ style={[a.flex_row, a.py_xs, a.gap_sm]}>
a.flex_row,
a.py_xs,
platform({native: [a.gap_sm], web: [a.gap_md]}),
]}>
<Toggle.Radio /> <Toggle.Radio />
<Toggle.LabelText <Toggle.LabelText
style={[ style={[
@@ -16,7 +16,7 @@ import {useNotificationSettingsQuery} from '#/state/queries/notifications/settin
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition' import {Admonition} from '#/components/Admonition'
import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At' import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
// import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging' import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble' import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic' import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
import { import {
@@ -183,20 +183,19 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings} showSkeleton={!settings}
/> />
</SettingsList.LinkItem> </SettingsList.LinkItem>
{/* <SettingsList.LinkItem <SettingsList.LinkItem
label={_(msg`Settings for activity alerts`)} label={_(msg`Settings for activity alerts`)}
to={{screen: 'ActivityNotificationSettings'}} to={{screen: 'ActivityNotificationSettings'}}
contentContainerStyle={[a.align_start]}> contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} /> <SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle <ItemTextWithSubtitle
titleText={<Trans>Activity alerts</Trans>} titleText={<Trans>Activity from others</Trans>}
subtitleText={ subtitleText={
<SettingPreview preference={settings?.subscribedPost} /> <SettingPreview preference={settings?.subscribedPost} />
} }
showSkeleton={!settings} showSkeleton={!settings}
/> />
</SettingsList.LinkItem> */} </SettingsList.LinkItem>
<SettingsList.LinkItem <SettingsList.LinkItem
label={_( label={_(
msg`Settings for notifications for likes of your reposts`, msg`Settings for notifications for likes of your reposts`,
@@ -1,14 +1,17 @@
import {View} from 'react-native' import {View} from 'react-native'
import {type AppBskyNotificationDeclaration} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useNotificationDeclarationQuery} from '#/state/queries/activity-subscriptions'
import {useAppPasswordsQuery} from '#/state/queries/app-passwords' import {useAppPasswordsQuery} from '#/state/queries/app-passwords'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Admonition from '#/components/Admonition' import * as Admonition from '#/components/Admonition'
import {BellRinging_Stroke2_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlashIcon} from '#/components/icons/EyeSlash'
import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key' import {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield' import {ShieldCheck_Stroke2_Corner0_Rounded as ShieldIcon} from '#/components/icons/Shield'
@@ -16,6 +19,7 @@ import * as Layout from '#/components/Layout'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {Email2FAToggle} from './components/Email2FAToggle' import {Email2FAToggle} from './components/Email2FAToggle'
import {PwiOptOut} from './components/PwiOptOut' import {PwiOptOut} from './components/PwiOptOut'
import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
type Props = NativeStackScreenProps< type Props = NativeStackScreenProps<
CommonNavigatorParams, CommonNavigatorParams,
@@ -26,6 +30,11 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
const t = useTheme() const t = useTheme()
const {data: appPasswords} = useAppPasswordsQuery() const {data: appPasswords} = useAppPasswordsQuery()
const {currentAccount} = useSession() const {currentAccount} = useSession()
const {
data: notificationDeclaration,
isPending,
isError,
} = useNotificationDeclarationQuery()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -71,6 +80,24 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
</SettingsList.BadgeText> </SettingsList.BadgeText>
)} )}
</SettingsList.LinkItem> </SettingsList.LinkItem>
<SettingsList.LinkItem
label={_(msg`Settings for activity alerts`)}
to={{screen: 'ActivityPrivacySettings'}}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={BellRingingIcon} />
<ItemTextWithSubtitle
titleText={
<Trans>Allow others to be notified of your posts</Trans>
}
subtitleText={
<NotificationDeclaration
data={notificationDeclaration}
isError={isError}
/>
}
showSkeleton={isPending}
/>
</SettingsList.LinkItem>
<SettingsList.Divider /> <SettingsList.Divider />
<SettingsList.Group> <SettingsList.Group>
<SettingsList.ItemIcon icon={EyeSlashIcon} /> <SettingsList.ItemIcon icon={EyeSlashIcon} />
@@ -111,3 +138,26 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
</Layout.Screen> </Layout.Screen>
) )
} }
function NotificationDeclaration({
data,
isError,
}: {
data?: {
value: AppBskyNotificationDeclaration.Record
}
isError?: boolean
}) {
if (isError) {
return <Trans>Error loading preference</Trans>
}
switch (data?.value?.allowSubscriptions) {
case 'mutuals':
return <Trans>Only followers who I follow</Trans>
case 'none':
return <Trans>No one</Trans>
case 'followers':
default:
return <Trans>Anyone who follows me</Trans>
}
}
+17 -6
View File
@@ -3,7 +3,7 @@ import {LayoutAnimation, Pressable, View} from 'react-native'
import {Linking} from 'react-native' import {Linking} from 'react-native'
import {useReducedMotion} from 'react-native-reanimated' import {useReducedMotion} from 'react-native-reanimated'
import {type AppBskyActorDefs, moderateProfile} from '@atproto/api' import {type AppBskyActorDefs, moderateProfile} from '@atproto/api'
import {msg, t, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native' import {useNavigation} from '@react-navigation/native'
import {type NativeStackScreenProps} from '@react-navigation/native-stack' import {type NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -16,7 +16,6 @@ import {
type CommonNavigatorParams, type CommonNavigatorParams,
type NavigationProp, type NavigationProp,
} from '#/lib/routes/types' } from '#/lib/routes/types'
import {useGate} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow' import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -64,6 +63,7 @@ import {
shouldShowVerificationCheckButton, shouldShowVerificationCheckButton,
VerificationCheckButton, VerificationCheckButton,
} from '#/components/verification/VerificationCheckButton' } from '#/components/verification/VerificationCheckButton'
import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'> type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
export function SettingsScreen({}: Props) { export function SettingsScreen({}: Props) {
@@ -82,7 +82,6 @@ export function SettingsScreen({}: Props) {
const {pendingDid, onPressSwitchAccount} = useAccountSwitcher() const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
const [showAccounts, setShowAccounts] = useState(false) const [showAccounts, setShowAccounts] = useState(false)
const [showDevOptions, setShowDevOptions] = useState(false) const [showDevOptions, setShowDevOptions] = useState(false)
const gate = useGate()
return ( return (
<Layout.Screen> <Layout.Screen>
@@ -183,7 +182,6 @@ export function SettingsScreen({}: Props) {
<Trans>Moderation</Trans> <Trans>Moderation</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </SettingsList.LinkItem>
{gate('reengagement_features') && (
<SettingsList.LinkItem <SettingsList.LinkItem
to="/settings/notifications" to="/settings/notifications"
label={_(msg`Notifications`)}> label={_(msg`Notifications`)}>
@@ -192,7 +190,6 @@ export function SettingsScreen({}: Props) {
<Trans>Notifications</Trans> <Trans>Notifications</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.LinkItem> </SettingsList.LinkItem>
)}
<SettingsList.LinkItem <SettingsList.LinkItem
to="/settings/content-and-media" to="/settings/content-and-media"
label={_(msg`Content and media`)}> label={_(msg`Content and media`)}>
@@ -364,6 +361,7 @@ function DevOptions() {
const onboardingDispatch = useOnboardingDispatch() const onboardingDispatch = useOnboardingDispatch()
const navigation = useNavigation<NavigationProp>() const navigation = useNavigation<NavigationProp>()
const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration() const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
const resetOnboarding = async () => { const resetOnboarding = async () => {
navigation.navigate('Home') navigation.navigate('Home')
@@ -384,7 +382,11 @@ function DevOptions() {
...persisted.get('reminders'), ...persisted.get('reminders'),
lastEmailConfirm: lastEmailConfirm.toISOString(), lastEmailConfirm: lastEmailConfirm.toISOString(),
}) })
Toast.show(t`You probably want to restart the app now.`) Toast.show(_(msg`You probably want to restart the app now.`))
}
const onPressActySubsUnNudge = () => {
setActyNotifNudged(false)
} }
return ( return (
@@ -431,6 +433,15 @@ function DevOptions() {
<Trans>Unsnooze email reminder</Trans> <Trans>Unsnooze email reminder</Trans>
</SettingsList.ItemText> </SettingsList.ItemText>
</SettingsList.PressableItem> </SettingsList.PressableItem>
{actyNotifNudged && (
<SettingsList.PressableItem
onPress={onPressActySubsUnNudge}
label={_(msg`Reset activity subscription nudge`)}>
<SettingsList.ItemText>
<Trans>Reset activity subscription nudge</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
)}
<SettingsList.PressableItem <SettingsList.PressableItem
onPress={() => clearAllStorage()} onPress={() => clearAllStorage()}
label={_(msg`Clear all storage data`)}> label={_(msg`Clear all storage data`)}>
@@ -1,15 +1,20 @@
import React, {useContext, useMemo} from 'react' import {createContext, useContext, useMemo} from 'react'
import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native' import {
type GestureResponderEvent,
type StyleProp,
View,
type ViewStyle,
} from 'react-native'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {atoms as a, useTheme, ViewStyleProp} from '#/alf' import {atoms as a, useTheme, type ViewStyleProp} from '#/alf'
import * as Button from '#/components/Button' import * as Button from '#/components/Button'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Link, LinkProps} from '#/components/Link' import {Link, type LinkProps} from '#/components/Link'
import {createPortalGroup} from '#/components/Portal' import {createPortalGroup} from '#/components/Portal'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
const ItemContext = React.createContext({ const ItemContext = createContext({
destructive: false, destructive: false,
withinGroup: false, withinGroup: false,
}) })
@@ -91,7 +96,7 @@ export function Item({
a.px_xl, a.px_xl,
a.py_sm, a.py_sm,
a.align_center, a.align_center,
a.gap_md, a.gap_sm,
a.w_full, a.w_full,
a.flex_row, a.flex_row,
{minHeight: 48}, {minHeight: 48},
@@ -100,9 +105,9 @@ export function Item({
// existing padding // existing padding
a.pl_xl.paddingLeft + a.pl_xl.paddingLeft +
// icon // icon
28 + 24 +
// gap // gap
a.gap_md.gap, a.gap_sm.gap,
}, },
style, style,
]}> ]}>
@@ -175,7 +180,7 @@ export function PressableItem({
export function ItemIcon({ export function ItemIcon({
icon: Comp, icon: Comp,
size = 'xl', size = 'lg',
color: colorProp, color: colorProp,
}: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & { }: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
color?: string color?: string
+10 -3
View File
@@ -1,9 +1,10 @@
import {useEffect, useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import {type AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs, type AppBskyNotificationDefs} from '@atproto/api'
import {type QueryClient} from '@tanstack/react-query' import {type QueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
import {batchedUpdates} from '#/lib/batchedUpdates' import {batchedUpdates} from '#/lib/batchedUpdates'
import {findAllProfilesInQueryData as findAllProfilesInActivitySubscriptionsQueryData} from '#/state/queries/activity-subscriptions'
import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search' import {findAllProfilesInQueryData as findAllProfilesInActorSearchQueryData} from '#/state/queries/actor-search'
import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews' import {findAllProfilesInQueryData as findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers' import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
@@ -33,6 +34,7 @@ export interface ProfileShadow {
blockingUri: string | undefined blockingUri: string | undefined
verification: AppBskyActorDefs.VerificationState verification: AppBskyActorDefs.VerificationState
status: AppBskyActorDefs.StatusView | undefined status: AppBskyActorDefs.StatusView | undefined
activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined
} }
const shadows: WeakMap< const shadows: WeakMap<
@@ -114,8 +116,8 @@ export function updateProfileShadow(
value: Partial<ProfileShadow>, value: Partial<ProfileShadow>,
) { ) {
const cachedProfiles = findProfilesInCache(queryClient, did) const cachedProfiles = findProfilesInCache(queryClient, did)
for (let post of cachedProfiles) { for (let profile of cachedProfiles) {
shadows.set(post, {...shadows.get(post), ...value}) shadows.set(profile, {...shadows.get(profile), ...value})
} }
batchedUpdates(() => { batchedUpdates(() => {
emitter.emit(did, value) emitter.emit(did, value)
@@ -137,6 +139,10 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted, muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
blocking: blocking:
'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking, 'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
activitySubscription:
'activitySubscription' in shadow
? shadow.activitySubscription
: profile.viewer?.activitySubscription,
}, },
verification: verification:
'verification' in shadow ? shadow.verification : profile.verification, 'verification' in shadow ? shadow.verification : profile.verification,
@@ -171,4 +177,5 @@ function* findProfilesInCache(
yield* findAllProfilesInPostThreadV2QueryData(queryClient, did) yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
yield* findAllProfilesInKnownFollowersQueryData(queryClient, did) yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did) yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
} }
+130
View File
@@ -0,0 +1,130 @@
import {
type AppBskyActorDefs,
type AppBskyNotificationDeclaration,
type AppBskyNotificationListActivitySubscriptions,
} from '@atproto/api'
import {t} from '@lingui/macro'
import {
type InfiniteData,
type QueryClient,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import {useAgent, useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export const RQKEY_getActivitySubscriptions = ['activity-subscriptions']
export const RQKEY_getNotificationDeclaration = ['notification-declaration']
export function useActivitySubscriptionsQuery() {
const agent = useAgent()
return useInfiniteQuery({
queryKey: RQKEY_getActivitySubscriptions,
queryFn: async ({pageParam}) => {
const response =
await agent.app.bsky.notification.listActivitySubscriptions({
cursor: pageParam,
})
return response.data
},
initialPageParam: undefined as string | undefined,
getNextPageParam: prev => prev.cursor,
})
}
export function useNotificationDeclarationQuery() {
const agent = useAgent()
const {currentAccount} = useSession()
return useQuery({
queryKey: RQKEY_getNotificationDeclaration,
queryFn: async () => {
try {
const response = await agent.app.bsky.notification.declaration.get({
repo: currentAccount!.did,
rkey: 'self',
})
return response
} catch (err) {
if (
err instanceof Error &&
err.message.startsWith('Could not locate record')
) {
return {
value: {
$type: 'app.bsky.notification.declaration',
allowSubscriptions: 'followers',
} satisfies AppBskyNotificationDeclaration.Record,
}
} else {
throw err
}
}
},
})
}
export function useNotificationDeclarationMutation() {
const agent = useAgent()
const {currentAccount} = useSession()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (record: AppBskyNotificationDeclaration.Record) => {
const response = await agent.app.bsky.notification.declaration.put(
{
repo: currentAccount!.did,
rkey: 'self',
},
record,
)
return response
},
onMutate: value => {
queryClient.setQueryData(
RQKEY_getNotificationDeclaration,
(old?: {
uri: string
cid: string
value: AppBskyNotificationDeclaration.Record
}) => {
if (!old) return old
return {
value,
}
},
)
},
onError: () => {
Toast.show(t`Failed to update notification declaration`)
queryClient.invalidateQueries({
queryKey: RQKEY_getNotificationDeclaration,
})
},
})
}
export function* findAllProfilesInQueryData(
queryClient: QueryClient,
did: string,
): Generator<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyNotificationListActivitySubscriptions.OutputSchema>
>({
queryKey: RQKEY_getActivitySubscriptions,
})
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) {
continue
}
for (const page of queryData.pages) {
for (const subscription of page.subscriptions) {
if (subscription.did === did) {
yield subscription
}
}
}
}
}
+7 -12
View File
@@ -1,13 +1,13 @@
import { import {
AppBskyActorDefs, type AppBskyActorDefs,
AppBskyGraphDefs, type AppBskyGraphDefs,
AppBskyGraphGetList, type AppBskyGraphGetList,
BskyAgent, type BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import { import {
InfiniteData, type InfiniteData,
QueryClient, type QueryClient,
QueryKey, type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQuery, useQuery,
} from '@tanstack/react-query' } from '@tanstack/react-query'
@@ -99,10 +99,6 @@ export function* findAllProfilesInQueryData(
>({ >({
queryKey: [RQKEY_ROOT], queryKey: [RQKEY_ROOT],
}) })
for (const [_queryKey, queryData] of queryDatas) {
if (!queryData) {
continue
}
for (const [_queryKey, queryData] of queryDatas) { for (const [_queryKey, queryData] of queryDatas) {
if (!queryData?.pages) { if (!queryData?.pages) {
continue continue
@@ -118,7 +114,6 @@ export function* findAllProfilesInQueryData(
} }
} }
} }
}
const allQueryData = queryClient.getQueriesData< const allQueryData = queryClient.getQueriesData<
AppBskyGraphDefs.ListItemView[] AppBskyGraphDefs.ListItemView[]
@@ -1,4 +1,4 @@
import {AppBskyActorDefs} from '@atproto/api' import {type AppBskyActorDefs} from '@atproto/api'
import {useMutation, useQueryClient} from '@tanstack/react-query' import {useMutation, useQueryClient} from '@tanstack/react-query'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -19,7 +19,7 @@ export function useUpdateActorDeclaration({
return useMutation({ return useMutation({
mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => { mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => {
if (!currentAccount) throw new Error('Not signed in') if (!currentAccount) throw new Error('Not signed in')
const result = await agent.api.com.atproto.repo.putRecord({ const result = await agent.com.atproto.repo.putRecord({
repo: currentAccount.did, repo: currentAccount.did,
collection: 'chat.bsky.actor.declaration', collection: 'chat.bsky.actor.declaration',
rkey: 'self', rkey: 'self',
+7 -7
View File
@@ -18,30 +18,30 @@
import {useCallback, useEffect, useMemo, useRef} from 'react' import {useCallback, useEffect, useMemo, useRef} from 'react'
import { import {
AppBskyActorDefs, type AppBskyActorDefs,
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AtUri, AtUri,
moderatePost, moderatePost,
} from '@atproto/api' } from '@atproto/api'
import { import {
InfiniteData, type InfiniteData,
QueryClient, type QueryClient,
QueryKey, type QueryKey,
useInfiniteQuery, useInfiniteQuery,
useQueryClient, useQueryClient,
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {STALE} from '#/state/queries'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import { import {
didOrHandleUriMatches, didOrHandleUriMatches,
embedViewRecordToPostView, embedViewRecordToPostView,
getEmbeddedPost, getEmbeddedPost,
} from '../util' } from '../util'
import {FeedPage} from './types' import {type FeedPage} from './types'
import {useUnreadNotificationsApi} from './unread' import {useUnreadNotificationsApi} from './unread'
import {fetchPage} from './util' import {fetchPage} from './util'
+1
View File
@@ -48,6 +48,7 @@ type OtherNotificationType =
| 'unverified' | 'unverified'
| 'like-via-repost' | 'like-via-repost'
| 'repost-via-repost' | 'repost-via-repost'
| 'subscribed-post'
| 'unknown' | 'unknown'
type FeedNotificationBase = { type FeedNotificationBase = {
+11 -3
View File
@@ -28,6 +28,7 @@ const GROUPABLE_REASONS = [
'follow', 'follow',
'like-via-repost', 'like-via-repost',
'repost-via-repost', 'repost-via-repost',
'subscribed-post',
] ]
const MS_1HR = 1e3 * 60 * 60 const MS_1HR = 1e3 * 60 * 60
const MS_2DAY = MS_1HR * 48 const MS_2DAY = MS_1HR * 48
@@ -144,7 +145,8 @@ export function groupNotifications(
Math.abs(ts2 - ts) < MS_2DAY && Math.abs(ts2 - ts) < MS_2DAY &&
notif.reason === groupedNotif.notification.reason && notif.reason === groupedNotif.notification.reason &&
notif.reasonSubject === groupedNotif.notification.reasonSubject && notif.reasonSubject === groupedNotif.notification.reasonSubject &&
notif.author.did !== groupedNotif.notification.author.did (notif.author.did !== groupedNotif.notification.author.did ||
notif.reason === 'subscribed-post')
) { ) {
const nextIsFollowBack = const nextIsFollowBack =
notif.reason === 'follow' && notif.author.viewer?.following notif.reason === 'follow' && notif.author.viewer?.following
@@ -252,7 +254,8 @@ function toKnownType(
notif.reason === 'verified' || notif.reason === 'verified' ||
notif.reason === 'unverified' || notif.reason === 'unverified' ||
notif.reason === 'like-via-repost' || notif.reason === 'like-via-repost' ||
notif.reason === 'repost-via-repost' notif.reason === 'repost-via-repost' ||
notif.reason === 'subscribed-post'
) { ) {
return notif.reason as NotificationType return notif.reason as NotificationType
} }
@@ -263,7 +266,12 @@ function getSubjectUri(
type: NotificationType, type: NotificationType,
notif: AppBskyNotificationListNotifications.Notification, notif: AppBskyNotificationListNotifications.Notification,
): string | undefined { ): string | undefined {
if (type === 'reply' || type === 'quote' || type === 'mention') { if (
type === 'reply' ||
type === 'quote' ||
type === 'mention' ||
type === 'subscribed-post'
) {
return notif.uri return notif.uri
} else if ( } else if (
type === 'post-like' || type === 'post-like' ||
+6
View File
@@ -6,6 +6,7 @@ export enum Nux {
NeueTypography = 'NeueTypography', NeueTypography = 'NeueTypography',
ExploreInterestsCard = 'ExploreInterestsCard', ExploreInterestsCard = 'ExploreInterestsCard',
InitialVerificationAnnouncement = 'InitialVerificationAnnouncement', InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
ActivitySubscriptions = 'ActivitySubscriptions',
} }
export const nuxNames = new Set(Object.values(Nux)) export const nuxNames = new Set(Object.values(Nux))
@@ -23,10 +24,15 @@ export type AppNux = BaseNux<
id: Nux.InitialVerificationAnnouncement id: Nux.InitialVerificationAnnouncement
data: undefined data: undefined
} }
| {
id: Nux.ActivitySubscriptions
data: undefined
}
> >
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = { export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.NeueTypography]: undefined, [Nux.NeueTypography]: undefined,
[Nux.ExploreInterestsCard]: undefined, [Nux.ExploreInterestsCard]: undefined,
[Nux.InitialVerificationAnnouncement]: undefined, [Nux.InitialVerificationAnnouncement]: undefined,
[Nux.ActivitySubscriptions]: undefined,
} }
+6
View File
@@ -24,6 +24,7 @@ import {HomeFeedAPI} from '#/lib/api/feed/home'
import {LikesFeedAPI} from '#/lib/api/feed/likes' import {LikesFeedAPI} from '#/lib/api/feed/likes'
import {ListFeedAPI} from '#/lib/api/feed/list' import {ListFeedAPI} from '#/lib/api/feed/list'
import {MergeFeedAPI} from '#/lib/api/feed/merge' import {MergeFeedAPI} from '#/lib/api/feed/merge'
import {PostListFeedAPI} from '#/lib/api/feed/posts'
import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types' import {type FeedAPI, type ReasonFeedSource} from '#/lib/api/feed/types'
import {aggregateUserInterests} from '#/lib/api/feed/utils' import {aggregateUserInterests} from '#/lib/api/feed/utils'
import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip' import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
@@ -53,6 +54,7 @@ export type AuthorFilter =
| 'posts_with_video' | 'posts_with_video'
type FeedUri = string type FeedUri = string
type ListUri = string type ListUri = string
type PostsUriList = string
export type FeedDescriptor = export type FeedDescriptor =
| 'following' | 'following'
@@ -60,6 +62,7 @@ export type FeedDescriptor =
| `feedgen|${FeedUri}` | `feedgen|${FeedUri}`
| `likes|${ActorDid}` | `likes|${ActorDid}`
| `list|${ListUri}` | `list|${ListUri}`
| `posts|${PostsUriList}`
| 'demo' | 'demo'
export interface FeedParams { export interface FeedParams {
mergeFeedEnabled?: boolean mergeFeedEnabled?: boolean
@@ -488,6 +491,9 @@ function createApi({
} else if (feedDesc.startsWith('list')) { } else if (feedDesc.startsWith('list')) {
const [_, list] = feedDesc.split('|') const [_, list] = feedDesc.split('|')
return new ListFeedAPI({agent, feedParams: {list}}) return new ListFeedAPI({agent, feedParams: {list}})
} else if (feedDesc.startsWith('posts')) {
const [_, uriList] = feedDesc.split('|')
return new PostListFeedAPI({agent, feedParams: {uris: uriList.split(',')}})
} else if (feedDesc === 'demo') { } else if (feedDesc === 'demo') {
return new DemoFeedAPI({agent}) return new DemoFeedAPI({agent})
} else { } else {
@@ -0,0 +1,8 @@
import {device, useStorage} from '#/storage'
export function useActivitySubscriptionsNudged() {
const [activitySubscriptionsNudged = false, setActivitySubscriptionsNudged] =
useStorage(device, ['activitySubscriptionsNudged'])
return [activitySubscriptionsNudged, setActivitySubscriptionsNudged] as const
}
+1
View File
@@ -11,6 +11,7 @@ export type Device = {
trendingBetaEnabled: boolean trendingBetaEnabled: boolean
devMode: boolean devMode: boolean
demoMode: boolean demoMode: boolean
activitySubscriptionsNudged?: boolean
} }
export type Account = { export type Account = {
@@ -52,6 +52,7 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, platform, useTheme} from '#/alf' import {atoms as a, platform, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button' import {Button, ButtonText} from '#/components/Button'
import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
import { import {
ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
@@ -114,7 +115,9 @@ let NotificationFeedItem = ({
case 'unverified': { case 'unverified': {
return makeProfileLink(item.notification.author) return makeProfileLink(item.notification.author)
} }
case 'reply': { case 'reply':
case 'mention':
case 'quote': {
const uripReply = new AtUri(item.notification.uri) const uripReply = new AtUri(item.notification.uri)
return `/profile/${uripReply.host}/post/${uripReply.rkey}` return `/profile/${uripReply.host}/post/${uripReply.rkey}`
} }
@@ -126,6 +129,13 @@ let NotificationFeedItem = ({
} }
break break
} }
case 'subscribed-post': {
const posts: string[] = []
for (const post of [item.notification, ...(item.additional ?? [])]) {
posts.push(post.uri)
}
return `/notifications/activity?posts=${encodeURIComponent(posts.slice(0, 25).join(','))}`
}
} }
return '' return ''
@@ -155,7 +165,10 @@ let NotificationFeedItem = ({
href: makeProfileLink(author), href: makeProfileLink(author),
moderation: moderateProfile(author, moderationOpts), moderation: moderateProfile(author, moderationOpts),
})) || []), })) || []),
] ].filter(
(author, index, arr) =>
arr.findIndex(au => au.profile.did === author.profile.did) === index,
)
}, [item, moderationOpts]) }, [item, moderationOpts])
const niceTimestamp = niceDate(i18n, item.notification.indexedAt) const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
@@ -503,6 +516,42 @@ let NotificationFeedItem = ({
<Trans>{firstAuthorLink} reposted your repost</Trans> <Trans>{firstAuthorLink} reposted your repost</Trans>
) )
icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} /> icon = <RepostIcon size="xl" style={{color: t.palette.positive_600}} />
} else if (item.type === 'subscribed-post') {
const postsCount = 1 + (item.additional?.length || 0)
a11yLabel = hasMultipleAuthors
? _(
msg`New posts from ${firstAuthorName} and ${plural(
additionalAuthorsCount,
{
one: `${formattedAuthorsCount} other`,
other: `${formattedAuthorsCount} others`,
},
)}`,
)
: _(
msg`New ${plural(postsCount, {
one: 'post',
other: 'posts',
})} from ${firstAuthorName}`,
)
notificationContent = hasMultipleAuthors ? (
<Trans>
New posts from {firstAuthorLink} and{' '}
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
<Plural
value={additionalAuthorsCount}
one={`${formattedAuthorsCount} other`}
other={`${formattedAuthorsCount} others`}
/>
</Text>{' '}
</Trans>
) : (
<Trans>
New <Plural value={postsCount} one="post" other="posts" /> from{' '}
{firstAuthorLink}
</Trans>
)
icon = <BellRingingIcon size="xl" style={{color: t.palette.primary_500}} />
} else { } else {
return null return null
} }
@@ -613,7 +662,8 @@ let NotificationFeedItem = ({
{item.type === 'post-like' || {item.type === 'post-like' ||
item.type === 'repost' || item.type === 'repost' ||
item.type === 'like-via-repost' || item.type === 'like-via-repost' ||
item.type === 'repost-via-repost' ? ( item.type === 'repost-via-repost' ||
item.type === 'subscribed-post' ? (
<View style={[a.pt_2xs]}> <View style={[a.pt_2xs]}>
<AdditionalPostText post={item.subject} /> <AdditionalPostText post={item.subject} />
</View> </View>
+2 -1
View File
@@ -96,7 +96,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
a.font_bold, a.font_bold,
t.atoms.text, t.atoms.text,
a.leading_tight, a.leading_tight,
{maxWidth: '70%', flexShrink: 0}, a.flex_shrink_0,
{maxWidth: '70%'},
]}> ]}>
{forceLTR( {forceLTR(
sanitizeDisplayName( sanitizeDisplayName(
+87 -87
View File
@@ -63,10 +63,10 @@
"@atproto/xrpc" "^0.7.0" "@atproto/xrpc" "^0.7.0"
"@atproto/xrpc-server" "^0.8.0" "@atproto/xrpc-server" "^0.8.0"
"@atproto/api@^0.15.16": "@atproto/api@^0.15.21":
version "0.15.16" version "0.15.21"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.16.tgz#1962e7067e03a661e17c3164874596ef1e7ed7ad" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd"
integrity sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ== integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA==
dependencies: dependencies:
"@atproto/common-web" "^0.4.2" "@atproto/common-web" "^0.4.2"
"@atproto/lexicon" "^0.4.11" "@atproto/lexicon" "^0.4.11"
@@ -77,14 +77,14 @@
tlds "^1.234.0" tlds "^1.234.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/aws@^0.2.22": "@atproto/aws@^0.2.24":
version "0.2.22" version "0.2.24"
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.22.tgz#14a664c06e3569945e4ab143d3a8a03400c7d1de" resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891"
integrity sha512-xZ+0/zHHmpgzdLJGTDkFl5Wd39Wm5MyyMLdGYSzyt0wGTBmH6Ktp7ZgR8rmQVNYN1+VkMcdClAiNhg+BSH3mRw== integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ==
dependencies: dependencies:
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/repo" "^0.8.2" "@atproto/repo" "^0.8.4"
"@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-cloudfront" "^3.261.0"
"@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-kms" "^3.196.0"
"@aws-sdk/client-s3" "^3.224.0" "@aws-sdk/client-s3" "^3.224.0"
@@ -94,21 +94,21 @@
multiformats "^9.9.0" multiformats "^9.9.0"
uint8arrays "3.0.0" uint8arrays "3.0.0"
"@atproto/bsky@^0.0.161": "@atproto/bsky@^0.0.167":
version "0.0.161" version "0.0.167"
resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.161.tgz#671280c1d40f5c4eb0cc31d338a9e950acbf0ce0" resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0"
integrity sha512-L4uzadjt+oyVq3+W7rc1A+X2DyZDsTfeSD15w7k6+6JzICp32qavDuVjut3CIBqXCt7ykvSDujApyLsB/lcWJQ== integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA==
dependencies: dependencies:
"@atproto-labs/fetch-node" "0.1.9" "@atproto-labs/fetch-node" "0.1.9"
"@atproto-labs/xrpc-utils" "0.0.16" "@atproto-labs/xrpc-utils" "0.0.16"
"@atproto/api" "^0.15.16" "@atproto/api" "^0.15.21"
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/did" "^0.1.5" "@atproto/did" "^0.1.5"
"@atproto/identity" "^0.4.8" "@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11" "@atproto/lexicon" "^0.4.11"
"@atproto/repo" "^0.8.2" "@atproto/repo" "^0.8.4"
"@atproto/sync" "^0.1.26" "@atproto/sync" "^0.1.28"
"@atproto/syntax" "^0.4.0" "@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0" "@atproto/xrpc-server" "^0.8.0"
"@bufbuild/protobuf" "^1.5.0" "@bufbuild/protobuf" "^1.5.0"
@@ -218,21 +218,21 @@
"@noble/hashes" "^1.6.1" "@noble/hashes" "^1.6.1"
uint8arrays "3.0.0" uint8arrays "3.0.0"
"@atproto/dev-env@^0.3.144": "@atproto/dev-env@^0.3.150":
version "0.3.144" version "0.3.150"
resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.144.tgz#cd2949ff870ca4cde23b4c377b08740a2e64151f" resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a"
integrity sha512-ND0oGp7itSnXxlAHlFxYjGFyCcu0f4eSucImVtKRxTcW8UeyyTtJcQP8OyNvtC8j13YjbW124r0g25Wlm0j9XQ== integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ==
dependencies: dependencies:
"@atproto/api" "^0.15.16" "@atproto/api" "^0.15.21"
"@atproto/bsky" "^0.0.161" "@atproto/bsky" "^0.0.167"
"@atproto/bsync" "^0.0.20" "@atproto/bsync" "^0.0.20"
"@atproto/common-web" "^0.4.2" "@atproto/common-web" "^0.4.2"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8" "@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11" "@atproto/lexicon" "^0.4.11"
"@atproto/ozone" "^0.1.121" "@atproto/ozone" "^0.1.126"
"@atproto/pds" "^0.4.150" "@atproto/pds" "^0.4.156"
"@atproto/sync" "^0.1.26" "@atproto/sync" "^0.1.28"
"@atproto/syntax" "^0.4.0" "@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0" "@atproto/xrpc-server" "^0.8.0"
"@did-plc/lib" "^0.0.1" "@did-plc/lib" "^0.0.1"
@@ -259,18 +259,18 @@
"@atproto/common-web" "^0.4.2" "@atproto/common-web" "^0.4.2"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/jwk-jose@0.1.8": "@atproto/jwk-jose@0.1.9":
version "0.1.8" version "0.1.9"
resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.8.tgz#2dc8ad2cc900e7bc231add293f6518b06dc017ec" resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz#bd4a899ea2d497808300c40106795f5645c01f75"
integrity sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA== integrity sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ==
dependencies: dependencies:
"@atproto/jwk" "0.3.0" "@atproto/jwk" "0.4.0"
jose "^5.2.0" jose "^5.2.0"
"@atproto/jwk@0.3.0": "@atproto/jwk@0.4.0":
version "0.3.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.3.0.tgz#275fa676f6b5988ddedf4ee0475dd285de9b831b" resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.4.0.tgz#f32265be172492c38434c556a124b954f249cee8"
integrity sha512-MIAXyNMGu1tCNHjqW/8jqfE/wgWCIoK2cJ0mR6UxwhNPvkbe35TcpRYJdtQu/E6MUd7TziyDBa/GO4dKAiePhQ== integrity sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog==
dependencies: dependencies:
multiformats "^9.9.0" multiformats "^9.9.0"
zod "^3.23.8" zod "^3.23.8"
@@ -286,32 +286,32 @@
multiformats "^9.9.0" multiformats "^9.9.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/oauth-provider-api@0.1.4": "@atproto/oauth-provider-api@0.1.6":
version "0.1.4" version "0.1.6"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.4.tgz#a775182e3648dc693a04e3cb604eb62cd9ddfd8c" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.6.tgz#769a70caaac9b5144f9f867518523d1568a6b47c"
integrity sha512-3PRrf0gTAVMCETjtIH/3AaQaHBDbjsRBc/OYrlWBZ9IPplchBXtQGH/KcnjE4kK2Ef8p45qQSl3dNWg3EXsbHQ== integrity sha512-4Q6ZCnTmmdiWiA+KMrfbZmqjxTSgMe+YE68+3RccwOCIgPt171TiDHGKIayep9n1RDnuucVQoqvVXOT4kmAsjw==
dependencies: dependencies:
"@atproto/jwk" "0.3.0" "@atproto/jwk" "0.4.0"
"@atproto/oauth-types" "0.3.0" "@atproto/oauth-types" "0.4.0"
"@atproto/oauth-provider-frontend@0.1.8": "@atproto/oauth-provider-frontend@0.1.10":
version "0.1.8" version "0.1.10"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.8.tgz#21d944566c63f54524f239a10f7c65d150982f40" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.10.tgz#d7176819d0ae1401ca5d70f7afec253621901a79"
integrity sha512-uqfHv+n2xq7vTpuBP1Red7PhpaAbbJbwSbRsSfplJQ16XmF5NCMU8dHGCGRTEHngLZ9UquuIefN3w1QTrNzD0w== integrity sha512-bOFpi5OIxWv4Q9ci1+PAXEzIZaiu5inepC7pRFYqgqgLoCO0MWH/5Qkn/f6jMpDwPdtBqAiPg9tjE7E3le6NJA==
optionalDependencies: optionalDependencies:
"@atproto/oauth-provider-api" "0.1.4" "@atproto/oauth-provider-api" "0.1.6"
"@atproto/oauth-provider-ui@0.1.9": "@atproto/oauth-provider-ui@0.1.11":
version "0.1.9" version "0.1.11"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.9.tgz#8c43a1affa94ecb537072e6d569b8a24cdd42e72" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.11.tgz#cb6194ac0b93f1d4b5d6717f80c55a3a20a8c690"
integrity sha512-a6/VAeQWRMxpgnqo/TuqXg3EW2tO68jLh8Mv1uyV1NiZbT7fNlgkII/djIl3fLoEa95I3p236NZxjhKELSBbGg== integrity sha512-9fflyDt4Y3RDJIfbonxVeMbQtLLQrkQSDhWhPXp9xbZ/uYBddaAw+svBfFoMY7dxdlJbQeUPobsUctEm3qAILg==
optionalDependencies: optionalDependencies:
"@atproto/oauth-provider-api" "0.1.4" "@atproto/oauth-provider-api" "0.1.6"
"@atproto/oauth-provider@^0.9.1": "@atproto/oauth-provider@^0.9.3":
version "0.9.1" version "0.9.3"
resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.1.tgz#0147b75d1ad444455159f0a687ce87b3b49a2894" resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.3.tgz#047b2e520e5cf127385adddc1dca47207b0ca113"
integrity sha512-2Gm3jv45cGLmUQV0C4/orCJBsHu4wajy+JTN9f/ATX3vvjnFtAd/1GRvAMKDGXtdF7VIjNFlD+4lqhoDxYJpng== integrity sha512-TAhsCYDB/1twEA1vqjLAz7lxKI8W59eNs239MujE35Cc9l4lRHyMopoFv5JmgNnxDvloB5l6RxpTbXVC6wnKpQ==
dependencies: dependencies:
"@atproto-labs/fetch" "0.2.3" "@atproto-labs/fetch" "0.2.3"
"@atproto-labs/fetch-node" "0.1.9" "@atproto-labs/fetch-node" "0.1.9"
@@ -320,12 +320,12 @@
"@atproto-labs/simple-store-memory" "0.1.3" "@atproto-labs/simple-store-memory" "0.1.3"
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/did" "0.1.5" "@atproto/did" "0.1.5"
"@atproto/jwk" "0.3.0" "@atproto/jwk" "0.4.0"
"@atproto/jwk-jose" "0.1.8" "@atproto/jwk-jose" "0.1.9"
"@atproto/oauth-provider-api" "0.1.4" "@atproto/oauth-provider-api" "0.1.6"
"@atproto/oauth-provider-frontend" "0.1.8" "@atproto/oauth-provider-frontend" "0.1.10"
"@atproto/oauth-provider-ui" "0.1.9" "@atproto/oauth-provider-ui" "0.1.11"
"@atproto/oauth-types" "0.3.0" "@atproto/oauth-types" "0.4.0"
"@atproto/syntax" "0.4.0" "@atproto/syntax" "0.4.0"
"@hapi/accept" "^6.0.3" "@hapi/accept" "^6.0.3"
"@hapi/address" "^5.1.1" "@hapi/address" "^5.1.1"
@@ -339,20 +339,20 @@
jose "^5.2.0" jose "^5.2.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/oauth-types@0.3.0": "@atproto/oauth-types@0.4.0":
version "0.3.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.3.0.tgz#8d49d939486ac281bc13d0b1fe4462b7e519fdf0" resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.4.0.tgz#fb110717dd1e8593adffc6eaa85e7ab4f0713740"
integrity sha512-ptfsJARKODXfuOoDQag4a6PpEkDEj4Urz3jOmnQZy2YspPc/TNm1o0HglU0YehELv1vfhh9gEz40BJztPPhiLA== integrity sha512-FrRH9JsPw9H4JxfPDrbrI+pB102tbHTygajfHay7xwz78HPOjSbWPRgWW2hYS4w8vDYdB3PYbBj1jPoKetW7LA==
dependencies: dependencies:
"@atproto/jwk" "0.3.0" "@atproto/jwk" "0.4.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/ozone@^0.1.121": "@atproto/ozone@^0.1.126":
version "0.1.121" version "0.1.126"
resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.121.tgz#309b7e876f3b598ed4e79bb5a79e2346931588fe" resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd"
integrity sha512-kc3NxiXSPqQmWz8yXlV5cFnZ469ViQd0AexEMw467AcB8ikK1WSxhLsa1EiNAQuLOOpyeXSmAKGAUFHzSOIMpw== integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ==
dependencies: dependencies:
"@atproto/api" "^0.15.16" "@atproto/api" "^0.15.21"
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8" "@atproto/identity" "^0.4.8"
@@ -377,21 +377,21 @@
undici "^6.14.1" undici "^6.14.1"
ws "^8.12.0" ws "^8.12.0"
"@atproto/pds@^0.4.150": "@atproto/pds@^0.4.156":
version "0.4.150" version "0.4.156"
resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.150.tgz#45686b05b8ed46e265efa5231ab16e6eda72a8e8" resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392"
integrity sha512-CPT6H2uDTe4ZAyxQbws2dIlmdFFf6GQGwMc0OE3kI1wBBaLHprpexjM2Gd4ObtYNxGOOV0fwoCDAth8qqZ4XVw== integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w==
dependencies: dependencies:
"@atproto-labs/fetch-node" "0.1.9" "@atproto-labs/fetch-node" "0.1.9"
"@atproto-labs/xrpc-utils" "0.0.16" "@atproto-labs/xrpc-utils" "0.0.16"
"@atproto/api" "^0.15.16" "@atproto/api" "^0.15.21"
"@atproto/aws" "^0.2.22" "@atproto/aws" "^0.2.24"
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/crypto" "^0.4.4" "@atproto/crypto" "^0.4.4"
"@atproto/identity" "^0.4.8" "@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11" "@atproto/lexicon" "^0.4.11"
"@atproto/oauth-provider" "^0.9.1" "@atproto/oauth-provider" "^0.9.3"
"@atproto/repo" "^0.8.2" "@atproto/repo" "^0.8.4"
"@atproto/syntax" "^0.4.0" "@atproto/syntax" "^0.4.0"
"@atproto/xrpc" "^0.7.0" "@atproto/xrpc" "^0.7.0"
"@atproto/xrpc-server" "^0.8.0" "@atproto/xrpc-server" "^0.8.0"
@@ -424,10 +424,10 @@
undici "^6.19.8" undici "^6.19.8"
zod "^3.23.8" zod "^3.23.8"
"@atproto/repo@^0.8.2": "@atproto/repo@^0.8.4":
version "0.8.2" version "0.8.4"
resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.2.tgz#7953cb2c637c94505da76f74a784b2aae050c204" resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774"
integrity sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg== integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA==
dependencies: dependencies:
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/common-web" "^0.4.2" "@atproto/common-web" "^0.4.2"
@@ -439,15 +439,15 @@
varint "^6.0.0" varint "^6.0.0"
zod "^3.23.8" zod "^3.23.8"
"@atproto/sync@^0.1.26": "@atproto/sync@^0.1.28":
version "0.1.26" version "0.1.28"
resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.26.tgz#6be2876be612d9cd704452598ee679b2e912cfe3" resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf"
integrity sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA== integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA==
dependencies: dependencies:
"@atproto/common" "^0.4.11" "@atproto/common" "^0.4.11"
"@atproto/identity" "^0.4.8" "@atproto/identity" "^0.4.8"
"@atproto/lexicon" "^0.4.11" "@atproto/lexicon" "^0.4.11"
"@atproto/repo" "^0.8.2" "@atproto/repo" "^0.8.4"
"@atproto/syntax" "^0.4.0" "@atproto/syntax" "^0.4.0"
"@atproto/xrpc-server" "^0.8.0" "@atproto/xrpc-server" "^0.8.0"
multiformats "^9.9.0" multiformats "^9.9.0"