diff --git a/assets/icons/bellPlus_stroke2_corner0_rounded.svg b/assets/icons/bellPlus_stroke2_corner0_rounded.svg
new file mode 100644
index 0000000000..7e3349427a
--- /dev/null
+++ b/assets/icons/bellPlus_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/bellRinging_filled_corner0_rounded.svg b/assets/icons/bellRinging_filled_corner0_rounded.svg
new file mode 100644
index 0000000000..67be5d99d3
--- /dev/null
+++ b/assets/icons/bellRinging_filled_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/assets/images/activity_notifications_announcement.webp b/assets/images/activity_notifications_announcement.webp
new file mode 100644
index 0000000000..885d1b7d1f
Binary files /dev/null and b/assets/images/activity_notifications_announcement.webp differ
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index ef796920d6..755f2fa3f6 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error {
e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
+ e.GET("/notifications/activity", server.WebGeneric)
e.GET("/lists", server.WebGeneric)
e.GET("/moderation", 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/account", 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/interests", server.WebGeneric)
e.GET("/settings/about", server.WebGeneric)
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
index 9fdfcfd89d..4f8a6b892a 100644
--- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -45,10 +45,10 @@ class BackgroundNotificationHandler(
private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
// If oreo or higher
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
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"]
}
}
diff --git a/package.json b/package.json
index 7c1c3dee92..aae35f96ab 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
- "@atproto/api": "^0.15.16",
+ "@atproto/api": "^0.15.21",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
- "@atproto/dev-env": "^0.3.144",
+ "@atproto/dev-env": "^0.3.150",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f1a9c569d0..26a2b2a2ae 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -84,6 +84,7 @@ import {SearchScreen} from '#/screens/Search'
import {AboutSettingsScreen} from '#/screens/Settings/AboutSettings'
import {AccessibilitySettingsScreen} from '#/screens/Settings/AccessibilitySettings'
import {AccountSettingsScreen} from '#/screens/Settings/AccountSettings'
+import {ActivityPrivacySettingsScreen} from '#/screens/Settings/ActivityPrivacySettings'
import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
@@ -109,8 +110,10 @@ import {
} from '#/components/dialogs/EmailDialog'
import {router} from '#/routes'
import {Referrer} from '../modules/expo-bluesky-swiss-army'
+import {NotificationsActivityListScreen} from './screens/Notifications/ActivityList'
import {LegacyNotificationSettingsScreen} from './screens/Settings/LegacyNotificationSettings'
import {NotificationSettingsScreen} from './screens/Settings/NotificationSettings'
+import {ActivityNotificationSettingsScreen} from './screens/Settings/NotificationSettings/ActivityNotificationSettings'
import {LikeNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikeNotificationSettings'
import {LikesOnRepostsNotificationSettingsScreen} from './screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings'
import {MentionNotificationSettingsScreen} from './screens/Settings/NotificationSettings/MentionNotificationSettings'
@@ -390,6 +393,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ ActivityPrivacySettingsScreen}
+ options={{
+ title: title(msg`Privacy and Security`),
+ requireAuth: true,
+ }}
+ />
NotificationSettingsScreen}
@@ -459,6 +470,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
+ ActivityNotificationSettingsScreen}
+ options={{
+ title: title(msg`Activity notifications`),
+ requireAuth: true,
+ }}
+ />
MiscellaneousNotificationSettingsScreen}
@@ -524,6 +543,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
getComponent={() => MessagesInboxScreen}
options={{title: title(msg`Chat request inbox`), requireAuth: true}}
/>
+ NotificationsActivityListScreen}
+ options={{title: title(msg`Notifications`), requireAuth: true}}
+ />
LegacyNotificationSettingsScreen}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 30b26beadf..4aec74880a 100644
--- a/src/components/ProfileCard.tsx
+++ b/src/components/ProfileCard.tsx
@@ -11,6 +11,8 @@ import {useLingui} from '@lingui/react'
import {useActorStatus} from '#/lib/actor-status'
import {getModerationCauseKey} from '#/lib/moderation'
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 {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -18,7 +20,7 @@ import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
-import {atoms as a, useTheme} from '#/alf'
+import {atoms as a, platform, useTheme} from '#/alf'
import {
Button,
ButtonIcon,
@@ -183,14 +185,77 @@ export function AvatarPlaceholder() {
export function NameAndHandle({
profile,
moderationOpts,
+ inline = false,
+}: {
+ profile: bsky.profile.AnyProfileView
+ moderationOpts: ModerationOpts
+ inline?: boolean
+}) {
+ if (inline) {
+ return (
+
+ )
+ } else {
+ return (
+
+
+
+
+ )
+ }
+}
+
+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 (
-
-
-
+
+
+ {forceLTR(name)}
+
+ {verification.showBadge && (
+
+
+
+ )}
+
+ {NON_BREAKING_SPACE + handle}
+
)
}
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
index 446cf18fc6..fbdb969db6 100644
--- a/src/components/Tooltip/index.tsx
+++ b/src/components/Tooltip/index.tsx
@@ -3,6 +3,7 @@ import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
useRef,
useState,
@@ -30,31 +31,33 @@ const BUBBLE_SHADOW_OFFSET = ARROW_SIZE / 3 // vibes-based, provide more shadow
type TooltipContextType = {
position: 'top' | 'bottom'
- ready: boolean
+ visible: boolean
onVisibleChange: (visible: boolean) => void
}
+type TargetMeasurements = {
+ x: number
+ y: number
+ width: number
+ height: number
+}
+
type TargetContextType = {
- targetMeasurements:
- | {
- x: number
- y: number
- width: number
- height: number
- }
- | undefined
- targetRef: React.RefObject
+ targetMeasurements: TargetMeasurements | undefined
+ setTargetMeasurements: (measurements: TargetMeasurements) => void
+ shouldMeasure: boolean
}
const TooltipContext = createContext({
position: 'bottom',
- ready: false,
+ visible: false,
onVisibleChange: () => {},
})
const TargetContext = createContext({
targetMeasurements: undefined,
- targetRef: {current: null},
+ setTargetMeasurements: () => {},
+ shouldMeasure: false,
})
export function Outer({
@@ -68,21 +71,12 @@ export function Outer({
visible: boolean
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
- * tooltip.
+ * tooltip, which needs to wait for the target to be measured before
+ * actually being shown.
*/
- const [prevRequestVisible, setPrevRequestVisible] = useState<
- boolean | undefined
- >()
- /**
- * Needs to reference the element this Tooltip is attached to.
- */
- const targetRef = useRef(null)
+ const [visible, setVisible] = useState(false)
const [targetMeasurements, setTargetMeasurements] = useState<
| {
x: number
@@ -93,33 +87,24 @@ export function Outer({
| undefined
>(undefined)
- if (requestVisible && !prevRequestVisible) {
- setPrevRequestVisible(true)
-
- if (targetRef.current) {
- /*
- * 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)
+ if (requestVisible && !visible && targetMeasurements) {
+ setVisible(true)
+ } else if (!requestVisible && visible) {
+ setVisible(false)
setTargetMeasurements(undefined)
- setReady(false)
}
const ctx = useMemo(
- () => ({position, ready, onVisibleChange}),
- [position, ready, onVisibleChange],
+ () => ({position, visible, onVisibleChange}),
+ [position, visible, onVisibleChange],
)
const targetCtx = useMemo(
- () => ({targetMeasurements, targetRef}),
- [targetMeasurements, targetRef],
+ () => ({
+ targetMeasurements,
+ setTargetMeasurements,
+ shouldMeasure: requestVisible,
+ }),
+ [requestVisible, targetMeasurements, setTargetMeasurements],
)
return (
@@ -132,7 +117,20 @@ export function Outer({
}
export function Target({children}: {children: React.ReactNode}) {
- const {targetRef} = useContext(TargetContext)
+ const {shouldMeasure, setTargetMeasurements} = useContext(TargetContext)
+ const targetRef = useRef(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 (
@@ -148,13 +146,13 @@ export function Content({
children: React.ReactNode
label: string
}) {
- const {position, ready, onVisibleChange} = useContext(TooltipContext)
+ const {position, visible, onVisibleChange} = useContext(TooltipContext)
const {targetMeasurements} = useContext(TargetContext)
const requestClose = useCallback(() => {
onVisibleChange(false)
}, [onVisibleChange])
- if (!ready || !targetMeasurements) return null
+ if (!visible || !targetMeasurements) return null
return (
diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx
index 739a714cd4..fc5808d7ad 100644
--- a/src/components/Tooltip/index.web.tsx
+++ b/src/components/Tooltip/index.web.tsx
@@ -13,10 +13,12 @@ import {Text} from '#/components/Typography'
type TooltipContextType = {
position: 'top' | 'bottom'
+ onVisibleChange: (open: boolean) => void
}
const TooltipContext = createContext({
position: 'bottom',
+ onVisibleChange: () => {},
})
export function Outer({
@@ -30,7 +32,10 @@ export function Outer({
visible: boolean
onVisibleChange: (visible: boolean) => void
}) {
- const ctx = useMemo(() => ({position}), [position])
+ const ctx = useMemo(
+ () => ({position, onVisibleChange}),
+ [position, onVisibleChange],
+ )
return (
{children}
@@ -54,7 +59,7 @@ export function Content({
label: string
}) {
const t = useTheme()
- const {position} = useContext(TooltipContext)
+ const {position, onVisibleChange} = useContext(TooltipContext)
return (
onVisibleChange(false)}
style={flatten([
a.rounded_sm,
select(t.name, {
diff --git a/src/components/activity-notifications/SubscribeProfileButton.tsx b/src/components/activity-notifications/SubscribeProfileButton.tsx
new file mode 100644
index 0000000000..71253dca9b
--- /dev/null
+++ b/src/components/activity-notifications/SubscribeProfileButton.tsx
@@ -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: [
+
+ Before you can get notifications for {name}'s posts, you must first
+ verify your email.
+ ,
+ ],
+ })
+
+ const isSubscribed =
+ profile.viewer?.activitySubscription?.post ||
+ profile.viewer?.activitySubscription?.reply
+
+ const Icon = isSubscribed ? BellRingingIcon : BellPlusIcon
+
+ return (
+ <>
+
+
+
+
+
+
+ Get notified about new posts
+
+
+
+
+
+ >
+ )
+}
diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx
new file mode 100644
index 0000000000..d1ab2842d0
--- /dev/null
+++ b/src/components/activity-notifications/SubscribeProfileDialog.tsx
@@ -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 (
+
+
+
+
+ )
+}
+
+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,
+ ) => {
+ 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,
+ ) => {
+ 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 = 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 (
+
+
+
+
+ Keep me posted
+
+
+ Get notified of this account’s activity
+
+
+
+ {includeProfile && (
+
+
+
+
+ )}
+
+
+
+
+
+ Posts
+
+
+
+
+
+ Replies
+
+
+
+
+
+
+ {error && (
+
+ Could not save changes: {cleanError(error)}
+
+ )}
+
+
+
+
+
+
+ )
+}
+
+function parseActivitySubscription(
+ sub?: AppBskyNotificationDefs.ActivitySubscription,
+): Un$Typed {
+ if (!sub) return {post: false, reply: false}
+ const {post, reply} = sub
+ return {post, reply}
+}
diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx
new file mode 100644
index 0000000000..b9f3979ed4
--- /dev/null
+++ b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ New Feature
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get notified when someone posts
+
+
+
+ You can now choose to be notified when specific people post. If
+ there’s someone you want timely updates from, go to their
+ profile and find the new bell icon near the follow button.
+
+
+
+
+ {!isWeb && (
+
+ )}
+
+
+
+
+
+ )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 11377e1ded..8096a0141f 100644
--- a/src/components/dialogs/nuxs/index.tsx
+++ b/src/components/dialogs/nuxs/index.tsx
@@ -11,12 +11,12 @@ import {
import {useProfileQuery} from '#/state/queries/profile'
import {type SessionAccount, useSession} from '#/state/session'
import {useOnboardingState} from '#/state/shell'
-import {InitialVerificationAnnouncement} from '#/components/dialogs/nuxs/InitialVerificationAnnouncement'
+import {ActivitySubscriptionsNUX} from '#/components/dialogs/nuxs/ActivitySubscriptions'
/*
* NUXs
*/
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 = {
activeNux: Nux | undefined
@@ -33,9 +33,12 @@ const queuedNuxs: {
}) => boolean
}[] = [
{
- id: Nux.InitialVerificationAnnouncement,
+ id: Nux.ActivitySubscriptions,
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(() => {
- if (snoozed) return
+ if (snoozed) return // comment this out to test
if (!nuxs) return
for (const {id, enabled} of queuedNuxs) {
@@ -119,7 +122,7 @@ function Inner({
// check if completed first
if (nux && nux.completed) {
- continue
+ continue // comment this out to test
}
// then check gate (track exposure)
@@ -172,9 +175,7 @@ function Inner({
return (
{/*For example, activeNux === Nux.NeueTypography && */}
- {activeNux === Nux.InitialVerificationAnnouncement && (
-
- )}
+ {activeNux === Nux.ActivitySubscriptions && }
)
}
diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts
index 0cc5104840..ba8f0169d6 100644
--- a/src/components/dialogs/nuxs/utils.ts
+++ b/src/components/dialogs/nuxs/utils.ts
@@ -16,3 +16,18 @@ export function isDaysOld(days: number, createdAt?: string) {
if (isOldEnough) return true
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
+}
diff --git a/src/components/icons/BellPlus.tsx b/src/components/icons/BellPlus.tsx
new file mode 100644
index 0000000000..cd29de1979
--- /dev/null
+++ b/src/components/icons/BellPlus.tsx
@@ -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',
+})
diff --git a/src/components/icons/BellRinging.tsx b/src/components/icons/BellRinging.tsx
index b174fcedc2..11981a7a38 100644
--- a/src/components/icons/BellRinging.tsx
+++ b/src/components/icons/BellRinging.tsx
@@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE'
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',
})
+
+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',
+})
diff --git a/src/lib/api/feed/list.ts b/src/lib/api/feed/list.ts
index 9744e3d4ce..9697b0aaf3 100644
--- a/src/lib/api/feed/list.ts
+++ b/src/lib/api/feed/list.ts
@@ -1,20 +1,20 @@
import {
- AppBskyFeedDefs,
- AppBskyFeedGetListFeed as GetListFeed,
- BskyAgent,
+ type Agent,
+ type AppBskyFeedDefs,
+ type AppBskyFeedGetListFeed as GetListFeed,
} from '@atproto/api'
-import {FeedAPI, FeedAPIResponse} from './types'
+import {type FeedAPI, type FeedAPIResponse} from './types'
export class ListFeedAPI implements FeedAPI {
- agent: BskyAgent
+ agent: Agent
params: GetListFeed.QueryParams
constructor({
agent,
feedParams,
}: {
- agent: BskyAgent
+ agent: Agent
feedParams: GetListFeed.QueryParams
}) {
this.agent = agent
diff --git a/src/lib/api/feed/posts.ts b/src/lib/api/feed/posts.ts
new file mode 100644
index 0000000000..33eff50997
--- /dev/null
+++ b/src/lib/api/feed/posts.ts
@@ -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 {
+ if (this.peek) return this.peek
+ throw new Error('Has not fetched yet')
+ }
+
+ async fetch({}: {}): Promise {
+ 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: [],
+ }
+ }
+}
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 311f38a793..6c3e7deb82 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -1,6 +1,6 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
-import {type AppBskyNotificationListNotifications} from '@atproto/api'
+import {AtUri} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {CommonActions, useNavigation} from '@react-navigation/native'
@@ -32,6 +32,7 @@ export type NotificationReason =
| 'repost-via-repost'
| 'verified'
| 'unverified'
+ | 'subscribed-post'
/**
* Manually overridden type, but retains the possibility of
@@ -112,61 +113,68 @@ export function useNotificationsHandler() {
})
Notifications.setNotificationChannelAsync(
- 'like' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'like' satisfies NotificationReason,
{
name: _(msg`Likes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'repost' satisfies NotificationReason,
{
name: _(msg`Reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'reply' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'reply' satisfies NotificationReason,
{
name: _(msg`Replies`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'mention' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'mention' satisfies NotificationReason,
{
name: _(msg`Mentions`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'quote' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'quote' satisfies NotificationReason,
{
name: _(msg`Quotes`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'follow' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'follow' satisfies NotificationReason,
{
name: _(msg`New followers`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'like-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'like-via-repost' satisfies NotificationReason,
{
name: _(msg`Likes of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
- 'repost-via-repost' satisfies AppBskyNotificationListNotifications.Notification['reason'],
+ 'repost-via-repost' satisfies NotificationReason,
{
name: _(msg`Reposts of your reposts`),
importance: Notifications.AndroidImportance.HIGH,
},
)
+ Notifications.setNotificationChannelAsync(
+ 'subscribed-post' satisfies NotificationReason,
+ {
+ name: _(msg`Activity from others`),
+ importance: Notifications.AndroidImportance.HIGH,
+ },
+ )
}, [_])
useEffect(() => {
@@ -220,6 +228,23 @@ export function useNotificationsHandler() {
}
} else {
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 'repost':
case 'follow':
@@ -231,6 +256,7 @@ export function useNotificationsHandler() {
case 'repost-via-repost':
case 'verified':
case 'unverified':
+ default:
resetToTab('NotificationsTab')
break
// TODO implement these after we have an idea of how to handle each individual case
diff --git a/src/lib/moderation/create-sanitized-display-name.ts b/src/lib/moderation/create-sanitized-display-name.ts
index 4f9584f918..4c62a5c03c 100644
--- a/src/lib/moderation/create-sanitized-display-name.ts
+++ b/src/lib/moderation/create-sanitized-display-name.ts
@@ -1,12 +1,9 @@
-import {AppBskyActorDefs} from '@atproto/api'
-
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
+import type * as bsky from '#/types/bsky'
export function createSanitizedDisplayName(
- profile:
- | AppBskyActorDefs.ProfileViewBasic
- | AppBskyActorDefs.ProfileViewDetailed,
+ profile: bsky.profile.AnyProfileView,
noAt = false,
) {
if (profile.displayName != null && profile.displayName !== '') {
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index c92be34c23..b1db5caa64 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -51,6 +51,7 @@ export type CommonNavigatorParams = {
AppearanceSettings: undefined
AccountSettings: undefined
PrivacyAndSecuritySettings: undefined
+ ActivityPrivacySettings: undefined
ContentAndMediaSettings: undefined
NotificationSettings: undefined
ReplyNotificationSettings: undefined
@@ -72,6 +73,7 @@ export type CommonNavigatorParams = {
MessagesConversation: {conversation: string; embed?: string; accept?: true}
MessagesSettings: undefined
MessagesInbox: undefined
+ NotificationsActivityList: {posts: string}
LegacyNotificationSettings: undefined
Feeds: undefined
Start: {name: string; rkey: string}
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index fca3f609af..3b1106480d 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -7,7 +7,6 @@ export type Gate =
| 'old_postonboarding'
| 'onboarding_add_video_feed'
| 'post_threads_v2_unspecced'
- | 'reengagement_features'
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 31af1be2b0..d18e69122d 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -443,4 +443,17 @@ export type MetricEvents = {
[key: string]: any
}
'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
+ }
}
diff --git a/src/routes.ts b/src/routes.ts
index b66a0ae53f..7fc673e2be 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -11,6 +11,7 @@ export const router = new Router({
Search: '/search',
Feeds: '/feeds',
Notifications: '/notifications',
+ NotificationsActivityList: '/notifications/activity',
LegacyNotificationSettings: '/notifications/settings',
Settings: '/settings',
Lists: '/lists',
@@ -50,6 +51,7 @@ export const router = new Router({
SavedFeeds: '/settings/saved-feeds',
AccountSettings: '/settings/account',
PrivacyAndSecuritySettings: '/settings/privacy-and-security',
+ ActivityPrivacySettings: '/settings/privacy-and-security/activity',
ContentAndMediaSettings: '/settings/content-and-media',
InterestsSettings: '/settings/interests',
AboutSettings: '/settings/about',
diff --git a/src/screens/Notifications/ActivityList.tsx b/src/screens/Notifications/ActivityList.tsx
new file mode 100644
index 0000000000..f87e340082
--- /dev/null
+++ b/src/screens/Notifications/ActivityList.tsx
@@ -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 (
+
+
+
+
+
+ Notifications
+
+
+
+
+ (
+
+ )}
+ renderEndOfFeed={() => }
+ />
+
+ )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 1639abaf0c..5dbf32c57f 100644
--- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx
+++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
@@ -15,7 +15,6 @@ import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
-import {type Shadow} from '#/state/cache/types'
import {
useProfileBlockMutationQueue,
useProfileFollowMutationQueue,
@@ -24,6 +23,7 @@ import {useRequireAuth, useSession} from '#/state/session'
import {ProfileMenu} from '#/view/com/profile/ProfileMenu'
import * as Toast from '#/view/com/util/Toast'
import {atoms as a, platform, useBreakpoints, useTheme} from '#/alf'
+import {SubscribeProfileButton} from '#/components/activity-notifications/SubscribeProfileButton'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {useDialogControl} from '#/components/Dialog'
import {MessageProfileButton} from '#/components/dms/MessageProfileButton'
@@ -58,8 +58,8 @@ let ProfileHeaderStandard = ({
}: Props): React.ReactNode => {
const t = useTheme()
const {gtMobile} = useBreakpoints()
- const profile: Shadow =
- useProfileShadow(profileUnshadowed)
+ const profile =
+ useProfileShadow(profileUnshadowed)
const {currentAccount, hasSession} = useSession()
const {_} = useLingui()
const moderation = useMemo(
@@ -134,13 +134,26 @@ let ProfileHeaderStandard = ({
}
}, [_, queueUnblock])
- const isMe = React.useMemo(
+ const isMe = useMemo(
() => currentAccount?.did === profile.did,
[currentAccount, 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 (
+ {hasSession && subscriptionsAllowed && (
+
+ )}
{hasSession && }