about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-07-02 00:36:04 +0300
committerGitHub <noreply@github.com>2025-07-01 14:36:04 -0700
commitbc072570d27e1f397406daea355570f5aec95647 (patch)
tree0d698c0bababd9b5e221df763a1ab15744ebdb71 /src
parent8f9a8ddce022e328b07b793c3f1500e1c423ef73 (diff)
downloadvoidsky-bc072570d27e1f397406daea355570f5aec95647.tar.zst
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>
Diffstat (limited to 'src')
-rw-r--r--src/Navigation.tsx24
-rw-r--r--src/components/ProfileCard.tsx73
-rw-r--r--src/components/Tooltip/index.tsx94
-rw-r--r--src/components/Tooltip/index.web.tsx10
-rw-r--r--src/components/activity-notifications/SubscribeProfileButton.tsx89
-rw-r--r--src/components/activity-notifications/SubscribeProfileDialog.tsx309
-rw-r--r--src/components/dialogs/nuxs/ActivitySubscriptions.tsx177
-rw-r--r--src/components/dialogs/nuxs/index.tsx19
-rw-r--r--src/components/dialogs/nuxs/utils.ts15
-rw-r--r--src/components/icons/BellPlus.tsx5
-rw-r--r--src/components/icons/BellRinging.tsx4
-rw-r--r--src/lib/api/feed/list.ts12
-rw-r--r--src/lib/api/feed/posts.ts52
-rw-r--r--src/lib/hooks/useNotificationHandler.ts44
-rw-r--r--src/lib/moderation/create-sanitized-display-name.ts7
-rw-r--r--src/lib/routes/types.ts2
-rw-r--r--src/lib/statsig/gates.ts1
-rw-r--r--src/logger/metrics.ts13
-rw-r--r--src/routes.ts2
-rw-r--r--src/screens/Notifications/ActivityList.tsx44
-rw-r--r--src/screens/Profile/Header/ProfileHeaderStandard.tsx27
-rw-r--r--src/screens/Settings/AccessibilitySettings.tsx19
-rw-r--r--src/screens/Settings/ActivityPrivacySettings.tsx140
-rw-r--r--src/screens/Settings/AppPasswords.tsx6
-rw-r--r--src/screens/Settings/AppearanceSettings.tsx9
-rw-r--r--src/screens/Settings/ExternalMediaPreferences.tsx7
-rw-r--r--src/screens/Settings/FollowingFeedPreferences.tsx5
-rw-r--r--src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx263
-rw-r--r--src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx30
-rw-r--r--src/screens/Settings/NotificationSettings/index.tsx9
-rw-r--r--src/screens/Settings/PrivacyAndSecuritySettings.tsx50
-rw-r--r--src/screens/Settings/Settings.tsx39
-rw-r--r--src/screens/Settings/components/SettingsList.tsx23
-rw-r--r--src/state/cache/profile-shadow.ts13
-rw-r--r--src/state/queries/activity-subscriptions.ts130
-rw-r--r--src/state/queries/list-members.ts33
-rw-r--r--src/state/queries/messages/actor-declaration.ts4
-rw-r--r--src/state/queries/notifications/feed.ts14
-rw-r--r--src/state/queries/notifications/types.ts1
-rw-r--r--src/state/queries/notifications/util.ts14
-rw-r--r--src/state/queries/nuxs/definitions.ts6
-rw-r--r--src/state/queries/post-feed.ts6
-rw-r--r--src/storage/hooks/activity-subscriptions-nudged.ts8
-rw-r--r--src/storage/schema.ts1
-rw-r--r--src/view/com/notifications/NotificationFeedItem.tsx56
-rw-r--r--src/view/com/util/PostMeta.tsx3
46 files changed, 1715 insertions, 197 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index f1a9c569d..26a2b2a2a 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'
@@ -391,6 +394,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
         }}
       />
       <Stack.Screen
+        name="ActivityPrivacySettings"
+        getComponent={() => ActivityPrivacySettingsScreen}
+        options={{
+          title: title(msg`Privacy and Security`),
+          requireAuth: true,
+        }}
+      />
+      <Stack.Screen
         name="NotificationSettings"
         getComponent={() => NotificationSettingsScreen}
         options={{title: title(msg`Notification settings`), requireAuth: true}}
@@ -460,6 +471,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
         }}
       />
       <Stack.Screen
+        name="ActivityNotificationSettings"
+        getComponent={() => ActivityNotificationSettingsScreen}
+        options={{
+          title: title(msg`Activity notifications`),
+          requireAuth: true,
+        }}
+      />
+      <Stack.Screen
         name="MiscellaneousNotificationSettings"
         getComponent={() => MiscellaneousNotificationSettingsScreen}
         options={{
@@ -525,6 +544,11 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
         options={{title: title(msg`Chat request inbox`), requireAuth: true}}
       />
       <Stack.Screen
+        name="NotificationsActivityList"
+        getComponent={() => NotificationsActivityListScreen}
+        options={{title: title(msg`Notifications`), requireAuth: true}}
+      />
+      <Stack.Screen
         name="LegacyNotificationSettings"
         getComponent={() => LegacyNotificationSettingsScreen}
         options={{title: title(msg`Notification settings`), requireAuth: true}}
diff --git a/src/components/ProfileCard.tsx b/src/components/ProfileCard.tsx
index 30b26bead..4aec74880 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 (
+      <InlineNameAndHandle profile={profile} moderationOpts={moderationOpts} />
+    )
+  } else {
+    return (
+      <View style={[a.flex_1]}>
+        <Name profile={profile} moderationOpts={moderationOpts} />
+        <Handle profile={profile} />
+      </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_1]}>
-      <Name profile={profile} moderationOpts={moderationOpts} />
-      <Handle profile={profile} />
+    <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>
   )
 }
diff --git a/src/components/Tooltip/index.tsx b/src/components/Tooltip/index.tsx
index 446cf18fc..fbdb969db 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<View>
+  targetMeasurements: TargetMeasurements | undefined
+  setTargetMeasurements: (measurements: TargetMeasurements) => void
+  shouldMeasure: boolean
 }
 
 const TooltipContext = createContext<TooltipContextType>({
   position: 'bottom',
-  ready: false,
+  visible: false,
   onVisibleChange: () => {},
 })
 
 const TargetContext = createContext<TargetContextType>({
   targetMeasurements: undefined,
-  targetRef: {current: null},
+  setTargetMeasurements: () => {},
+  shouldMeasure: false,
 })
 
 export function Outer({
@@ -69,20 +72,11 @@ export function Outer({
   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<View>(null)
+  const [visible, setVisible] = useState<boolean>(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<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 (
     <View collapsable={false} ref={targetRef}>
@@ -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 (
     <Portal>
diff --git a/src/components/Tooltip/index.web.tsx b/src/components/Tooltip/index.web.tsx
index 739a714cd..fc5808d7a 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<TooltipContextType>({
   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 (
     <Popover.Root open={visible} onOpenChange={onVisibleChange}>
       <TooltipContext.Provider value={ctx}>{children}</TooltipContext.Provider>
@@ -54,7 +59,7 @@ export function Content({
   label: string
 }) {
   const t = useTheme()
-  const {position} = useContext(TooltipContext)
+  const {position, onVisibleChange} = useContext(TooltipContext)
   return (
     <Popover.Portal>
       <Popover.Content
@@ -63,6 +68,7 @@ export function Content({
         side={position}
         sideOffset={4}
         collisionPadding={MIN_EDGE_SPACE}
+        onInteractOutside={() => 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 000000000..71253dca9
--- /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: [
+      <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}
+      />
+    </>
+  )
+}
diff --git a/src/components/activity-notifications/SubscribeProfileDialog.tsx b/src/components/activity-notifications/SubscribeProfileDialog.tsx
new file mode 100644
index 000000000..d1ab2842d
--- /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 (
+    <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 account’s 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}
+}
diff --git a/src/components/dialogs/nuxs/ActivitySubscriptions.tsx b/src/components/dialogs/nuxs/ActivitySubscriptions.tsx
new file mode 100644
index 000000000..b9f3979ed
--- /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 (
+    <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
+                there’s 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>
+  )
+}
diff --git a/src/components/dialogs/nuxs/index.tsx b/src/components/dialogs/nuxs/index.tsx
index 11377e1de..8096a0141 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 (
     <Context.Provider value={ctx}>
       {/*For example, activeNux === Nux.NeueTypography && <NeueTypography />*/}
-      {activeNux === Nux.InitialVerificationAnnouncement && (
-        <InitialVerificationAnnouncement />
-      )}
+      {activeNux === Nux.ActivitySubscriptions && <ActivitySubscriptionsNUX />}
     </Context.Provider>
   )
 }
diff --git a/src/components/dialogs/nuxs/utils.ts b/src/components/dialogs/nuxs/utils.ts
index 0cc510484..ba8f0169d 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 000000000..cd29de197
--- /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 b174fcedc..11981a7a3 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 9744e3d4c..9697b0aaf 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 000000000..33eff5099
--- /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<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: [],
+    }
+  }
+}
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 311f38a79..6c3e7deb8 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 4f9584f91..4c62a5c03 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 c92be34c2..b1db5caa6 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 fca3f609a..3b1106480 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 31af1be2b..d18e69122 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 b66a0ae53..7fc673e2b 100644
--- a/src/routes.ts
+++ b/src/routes.ts
@@ -11,6 +11,7 @@ export const router = new Router<AllNavigatableRoutes>({
   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<AllNavigatableRoutes>({
   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 000000000..f87e34008
--- /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 (
+    <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>
+  )
+}
diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx
index 1639abaf0..5dbf32c57 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<AppBskyActorDefs.ProfileViewDetailed> =
-    useProfileShadow(profileUnshadowed)
+  const profile =
+    useProfileShadow<AppBskyActorDefs.ProfileViewDetailed>(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 (
     <ProfileHeaderShell
       profile={profile}
@@ -198,6 +211,12 @@ let ProfileHeaderStandard = ({
             )
           ) : !profile.viewer?.blockedBy ? (
             <>
+              {hasSession && subscriptionsAllowed && (
+                <SubscribeProfileButton
+                  profile={profile}
+                  moderationOpts={moderationOpts}
+                />
+              )}
               {hasSession && <MessageProfileButton profile={profile} />}
 
               <Button
diff --git a/src/screens/Settings/AccessibilitySettings.tsx b/src/screens/Settings/AccessibilitySettings.tsx
index ee26697d2..dbabd2f6f 100644
--- a/src/screens/Settings/AccessibilitySettings.tsx
+++ b/src/screens/Settings/AccessibilitySettings.tsx
@@ -1,8 +1,8 @@
 import {msg, Trans} from '@lingui/macro'
 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 {
   useHapticsDisabled,
@@ -16,12 +16,10 @@ import {
 } from '#/state/preferences/large-alt-badge'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a} from '#/alf'
-import {Admonition} from '#/components/Admonition'
 import * as Toggle from '#/components/forms/Toggle'
 import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
 import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
 import * as Layout from '#/components/Layout'
-import {InlineLinkText} from '#/components/Link'
 
 type Props = NativeStackScreenProps<
   CommonNavigatorParams,
@@ -100,19 +98,6 @@ export function AccessibilitySettingsScreen({}: Props) {
               </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>
       </Layout.Content>
     </Layout.Screen>
diff --git a/src/screens/Settings/ActivityPrivacySettings.tsx b/src/screens/Settings/ActivityPrivacySettings.tsx
new file mode 100644
index 000000000..988195a36
--- /dev/null
+++ b/src/screens/Settings/ActivityPrivacySettings.tsx
@@ -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>
+  )
+}
diff --git a/src/screens/Settings/AppPasswords.tsx b/src/screens/Settings/AppPasswords.tsx
index 9a900a3ee..05ebcd80d 100644
--- a/src/screens/Settings/AppPasswords.tsx
+++ b/src/screens/Settings/AppPasswords.tsx
@@ -7,12 +7,12 @@ import Animated, {
   LinearTransition,
   StretchOutY,
 } from 'react-native-reanimated'
-import {ComAtprotoServerListAppPasswords} from '@atproto/api'
+import {type ComAtprotoServerListAppPasswords} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 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 {isWeb} from '#/platform/detection'
 import {
diff --git a/src/screens/Settings/AppearanceSettings.tsx b/src/screens/Settings/AppearanceSettings.tsx
index 4a8a61cd2..d0158aaa8 100644
--- a/src/screens/Settings/AppearanceSettings.tsx
+++ b/src/screens/Settings/AppearanceSettings.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import Animated, {
   FadeInUp,
   FadeOutUp,
@@ -9,14 +9,17 @@ import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
 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 {isNative} from '#/platform/detection'
 import {useSetThemePrefs, useThemePrefs} from '#/state/shell'
 import {SettingsListItem as AppIconSettingsListItem} from '#/screens/Settings/AppIconSettings/SettingsListItem'
 import {atoms as a, native, useAlf, useTheme} from '#/alf'
 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 {Phone_Stroke2_Corner0_Rounded as PhoneIcon} from '#/components/icons/Phone'
 import {TextSize_Stroke2_Corner0_Rounded as TextSize} from '#/components/icons/TextSize'
diff --git a/src/screens/Settings/ExternalMediaPreferences.tsx b/src/screens/Settings/ExternalMediaPreferences.tsx
index ae859295f..1f0040fb3 100644
--- a/src/screens/Settings/ExternalMediaPreferences.tsx
+++ b/src/screens/Settings/ExternalMediaPreferences.tsx
@@ -2,9 +2,12 @@ import {Fragment} from 'react'
 import {View} from 'react-native'
 import {Trans} from '@lingui/macro'
 
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
 import {
-  EmbedPlayerSource,
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {
+  type EmbedPlayerSource,
   externalEmbedLabels,
 } from '#/lib/strings/embed-player'
 import {
diff --git a/src/screens/Settings/FollowingFeedPreferences.tsx b/src/screens/Settings/FollowingFeedPreferences.tsx
index ea9455ab1..7f1ae1d32 100644
--- a/src/screens/Settings/FollowingFeedPreferences.tsx
+++ b/src/screens/Settings/FollowingFeedPreferences.tsx
@@ -1,7 +1,10 @@
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
+import {
+  type CommonNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
 import {
   usePreferencesQuery,
   useSetFeedViewPreferencesMutation,
diff --git a/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx
new file mode 100644
index 000000000..b00170f3a
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/ActivityNotificationSettings.tsx
@@ -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>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
index 487827d66..ce46541fd 100644
--- a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
+++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
@@ -5,7 +5,7 @@ import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {useGate} from '#/lib/statsig/statsig'
+import {logger} from '#/logger'
 import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
 import {atoms as a, platform, useTheme} from '#/alf'
 import * as Toggle from '#/components/forms/Toggle'
@@ -28,10 +28,6 @@ export function PreferenceControls({
   preference?: AppBskyNotificationDefs.Preference | FilterablePreference
   allowDisableInApp?: boolean
 }) {
-  const gate = useGate()
-
-  if (!gate('reengagement_features')) return null
-
   if (!preference)
     return (
       <View style={[a.w_full, a.pt_5xl, a.align_center]}>
@@ -78,6 +74,12 @@ export function Inner({
       push: change.includes('push'),
     } satisfies typeof preference
 
+    logger.metric('activityPreference:changeChannels', {
+      name,
+      push: newPreference.push,
+      list: newPreference.list,
+    })
+
     mutate({
       [name]: newPreference,
       ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
@@ -93,6 +95,8 @@ export function Inner({
       include: change,
     } satisfies typeof preference
 
+    logger.metric('activityPreference:changeFilter', {name, value: change})
+
     mutate({
       [name]: newPreference,
       ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
@@ -114,7 +118,7 @@ export function Inner({
               a.py_xs,
               platform({
                 native: [a.justify_between],
-                web: [a.flex_row_reverse, a.gap_md],
+                web: [a.flex_row_reverse, a.gap_sm],
               }),
             ]}>
             <Toggle.LabelText
@@ -131,7 +135,7 @@ export function Inner({
                 a.py_xs,
                 platform({
                   native: [a.justify_between],
-                  web: [a.flex_row_reverse, a.gap_md],
+                  web: [a.flex_row_reverse, a.gap_sm],
                 }),
               ]}>
               <Toggle.LabelText
@@ -159,11 +163,7 @@ export function Inner({
               <Toggle.Item
                 label={_(msg`Everyone`)}
                 name="all"
-                style={[
-                  a.flex_row,
-                  a.py_xs,
-                  platform({native: [a.gap_sm], web: [a.gap_md]}),
-                ]}>
+                style={[a.flex_row, a.py_xs, a.gap_sm]}>
                 <Toggle.Radio />
                 <Toggle.LabelText
                   style={[
@@ -177,11 +177,7 @@ export function Inner({
               <Toggle.Item
                 label={_(msg`People I follow`)}
                 name="follows"
-                style={[
-                  a.flex_row,
-                  a.py_xs,
-                  platform({native: [a.gap_sm], web: [a.gap_md]}),
-                ]}>
+                style={[a.flex_row, a.py_xs, a.gap_sm]}>
                 <Toggle.Radio />
                 <Toggle.LabelText
                   style={[
diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx
index 800493575..df7c9a35b 100644
--- a/src/screens/Settings/NotificationSettings/index.tsx
+++ b/src/screens/Settings/NotificationSettings/index.tsx
@@ -16,7 +16,7 @@ import {useNotificationSettingsQuery} from '#/state/queries/notifications/settin
 import {atoms as a} from '#/alf'
 import {Admonition} from '#/components/Admonition'
 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 {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
 import {
@@ -183,20 +183,19 @@ export function NotificationSettingsScreen({}: Props) {
                 showSkeleton={!settings}
               />
             </SettingsList.LinkItem>
-            {/* <SettingsList.LinkItem
+            <SettingsList.LinkItem
               label={_(msg`Settings for activity alerts`)}
               to={{screen: 'ActivityNotificationSettings'}}
               contentContainerStyle={[a.align_start]}>
               <SettingsList.ItemIcon icon={BellRingingIcon} />
-
               <ItemTextWithSubtitle
-                titleText={<Trans>Activity alerts</Trans>}
+                titleText={<Trans>Activity from others</Trans>}
                 subtitleText={
                   <SettingPreview preference={settings?.subscribedPost} />
                 }
                 showSkeleton={!settings}
               />
-            </SettingsList.LinkItem> */}
+            </SettingsList.LinkItem>
             <SettingsList.LinkItem
               label={_(
                 msg`Settings for notifications for likes of your reposts`,
diff --git a/src/screens/Settings/PrivacyAndSecuritySettings.tsx b/src/screens/Settings/PrivacyAndSecuritySettings.tsx
index 61a8f81cc..a85ad8372 100644
--- a/src/screens/Settings/PrivacyAndSecuritySettings.tsx
+++ b/src/screens/Settings/PrivacyAndSecuritySettings.tsx
@@ -1,14 +1,17 @@
 import {View} from 'react-native'
+import {type AppBskyNotificationDeclaration} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {type NativeStackScreenProps} from '@react-navigation/native-stack'
 
 import {type CommonNavigatorParams} from '#/lib/routes/types'
+import {useNotificationDeclarationQuery} from '#/state/queries/activity-subscriptions'
 import {useAppPasswordsQuery} from '#/state/queries/app-passwords'
 import {useSession} from '#/state/session'
 import * as SettingsList from '#/screens/Settings/components/SettingsList'
 import {atoms as a, useTheme} from '#/alf'
 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 {Key_Stroke2_Corner2_Rounded as KeyIcon} from '#/components/icons/Key'
 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 {Email2FAToggle} from './components/Email2FAToggle'
 import {PwiOptOut} from './components/PwiOptOut'
+import {ItemTextWithSubtitle} from './NotificationSettings/components/ItemTextWithSubtitle'
 
 type Props = NativeStackScreenProps<
   CommonNavigatorParams,
@@ -26,6 +30,11 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
   const t = useTheme()
   const {data: appPasswords} = useAppPasswordsQuery()
   const {currentAccount} = useSession()
+  const {
+    data: notificationDeclaration,
+    isPending,
+    isError,
+  } = useNotificationDeclarationQuery()
 
   return (
     <Layout.Screen>
@@ -71,6 +80,24 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
               </SettingsList.BadgeText>
             )}
           </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.Group>
             <SettingsList.ItemIcon icon={EyeSlashIcon} />
@@ -111,3 +138,26 @@ export function PrivacyAndSecuritySettingsScreen({}: Props) {
     </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>
+  }
+}
diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx
index e1d197070..aaba0b4b5 100644
--- a/src/screens/Settings/Settings.tsx
+++ b/src/screens/Settings/Settings.tsx
@@ -3,7 +3,7 @@ import {LayoutAnimation, Pressable, View} from 'react-native'
 import {Linking} from 'react-native'
 import {useReducedMotion} from 'react-native-reanimated'
 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 {useNavigation} from '@react-navigation/native'
 import {type NativeStackScreenProps} from '@react-navigation/native-stack'
@@ -16,7 +16,6 @@ import {
   type CommonNavigatorParams,
   type NavigationProp,
 } from '#/lib/routes/types'
-import {useGate} from '#/lib/statsig/statsig'
 import {sanitizeDisplayName} from '#/lib/strings/display-names'
 import {sanitizeHandle} from '#/lib/strings/handles'
 import {useProfileShadow} from '#/state/cache/profile-shadow'
@@ -64,6 +63,7 @@ import {
   shouldShowVerificationCheckButton,
   VerificationCheckButton,
 } from '#/components/verification/VerificationCheckButton'
+import {useActivitySubscriptionsNudged} from '#/storage/hooks/activity-subscriptions-nudged'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'Settings'>
 export function SettingsScreen({}: Props) {
@@ -82,7 +82,6 @@ export function SettingsScreen({}: Props) {
   const {pendingDid, onPressSwitchAccount} = useAccountSwitcher()
   const [showAccounts, setShowAccounts] = useState(false)
   const [showDevOptions, setShowDevOptions] = useState(false)
-  const gate = useGate()
 
   return (
     <Layout.Screen>
@@ -183,16 +182,14 @@ export function SettingsScreen({}: Props) {
               <Trans>Moderation</Trans>
             </SettingsList.ItemText>
           </SettingsList.LinkItem>
-          {gate('reengagement_features') && (
-            <SettingsList.LinkItem
-              to="/settings/notifications"
-              label={_(msg`Notifications`)}>
-              <SettingsList.ItemIcon icon={NotificationIcon} />
-              <SettingsList.ItemText>
-                <Trans>Notifications</Trans>
-              </SettingsList.ItemText>
-            </SettingsList.LinkItem>
-          )}
+          <SettingsList.LinkItem
+            to="/settings/notifications"
+            label={_(msg`Notifications`)}>
+            <SettingsList.ItemIcon icon={NotificationIcon} />
+            <SettingsList.ItemText>
+              <Trans>Notifications</Trans>
+            </SettingsList.ItemText>
+          </SettingsList.LinkItem>
           <SettingsList.LinkItem
             to="/settings/content-and-media"
             label={_(msg`Content and media`)}>
@@ -364,6 +361,7 @@ function DevOptions() {
   const onboardingDispatch = useOnboardingDispatch()
   const navigation = useNavigation<NavigationProp>()
   const {mutate: deleteChatDeclarationRecord} = useDeleteActorDeclaration()
+  const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
 
   const resetOnboarding = async () => {
     navigation.navigate('Home')
@@ -384,7 +382,11 @@ function DevOptions() {
       ...persisted.get('reminders'),
       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 (
@@ -431,6 +433,15 @@ function DevOptions() {
           <Trans>Unsnooze email reminder</Trans>
         </SettingsList.ItemText>
       </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
         onPress={() => clearAllStorage()}
         label={_(msg`Clear all storage data`)}>
diff --git a/src/screens/Settings/components/SettingsList.tsx b/src/screens/Settings/components/SettingsList.tsx
index 520df4118..6d1799047 100644
--- a/src/screens/Settings/components/SettingsList.tsx
+++ b/src/screens/Settings/components/SettingsList.tsx
@@ -1,15 +1,20 @@
-import React, {useContext, useMemo} from 'react'
-import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'
+import {createContext, useContext, useMemo} from 'react'
+import {
+  type GestureResponderEvent,
+  type StyleProp,
+  View,
+  type ViewStyle,
+} from 'react-native'
 
 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 {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 {Text} from '#/components/Typography'
 
-const ItemContext = React.createContext({
+const ItemContext = createContext({
   destructive: false,
   withinGroup: false,
 })
@@ -91,7 +96,7 @@ export function Item({
         a.px_xl,
         a.py_sm,
         a.align_center,
-        a.gap_md,
+        a.gap_sm,
         a.w_full,
         a.flex_row,
         {minHeight: 48},
@@ -100,9 +105,9 @@ export function Item({
             // existing padding
             a.pl_xl.paddingLeft +
             // icon
-            28 +
+            24 +
             // gap
-            a.gap_md.gap,
+            a.gap_sm.gap,
         },
         style,
       ]}>
@@ -175,7 +180,7 @@ export function PressableItem({
 
 export function ItemIcon({
   icon: Comp,
-  size = 'xl',
+  size = 'lg',
   color: colorProp,
 }: Omit<React.ComponentProps<typeof Button.ButtonIcon>, 'position'> & {
   color?: string
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts
index 0d3bb1b17..1489e65fd 100644
--- a/src/state/cache/profile-shadow.ts
+++ b/src/state/cache/profile-shadow.ts
@@ -1,9 +1,10 @@
 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 EventEmitter from 'eventemitter3'
 
 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 findAllProfilesInExploreFeedPreviewsQueryData} from '#/state/queries/explore-feed-previews'
 import {findAllProfilesInQueryData as findAllProfilesInKnownFollowersQueryData} from '#/state/queries/known-followers'
@@ -33,6 +34,7 @@ export interface ProfileShadow {
   blockingUri: string | undefined
   verification: AppBskyActorDefs.VerificationState
   status: AppBskyActorDefs.StatusView | undefined
+  activitySubscription: AppBskyNotificationDefs.ActivitySubscription | undefined
 }
 
 const shadows: WeakMap<
@@ -114,8 +116,8 @@ export function updateProfileShadow(
   value: Partial<ProfileShadow>,
 ) {
   const cachedProfiles = findProfilesInCache(queryClient, did)
-  for (let post of cachedProfiles) {
-    shadows.set(post, {...shadows.get(post), ...value})
+  for (let profile of cachedProfiles) {
+    shadows.set(profile, {...shadows.get(profile), ...value})
   }
   batchedUpdates(() => {
     emitter.emit(did, value)
@@ -137,6 +139,10 @@ function mergeShadow<TProfileView extends bsky.profile.AnyProfileView>(
       muted: 'muted' in shadow ? shadow.muted : profile.viewer?.muted,
       blocking:
         'blockingUri' in shadow ? shadow.blockingUri : profile.viewer?.blocking,
+      activitySubscription:
+        'activitySubscription' in shadow
+          ? shadow.activitySubscription
+          : profile.viewer?.activitySubscription,
     },
     verification:
       'verification' in shadow ? shadow.verification : profile.verification,
@@ -171,4 +177,5 @@ function* findProfilesInCache(
   yield* findAllProfilesInPostThreadV2QueryData(queryClient, did)
   yield* findAllProfilesInKnownFollowersQueryData(queryClient, did)
   yield* findAllProfilesInExploreFeedPreviewsQueryData(queryClient, did)
+  yield* findAllProfilesInActivitySubscriptionsQueryData(queryClient, did)
 }
diff --git a/src/state/queries/activity-subscriptions.ts b/src/state/queries/activity-subscriptions.ts
new file mode 100644
index 000000000..a81a67226
--- /dev/null
+++ b/src/state/queries/activity-subscriptions.ts
@@ -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
+        }
+      }
+    }
+  }
+}
diff --git a/src/state/queries/list-members.ts b/src/state/queries/list-members.ts
index 82c395518..152c7a5be 100644
--- a/src/state/queries/list-members.ts
+++ b/src/state/queries/list-members.ts
@@ -1,13 +1,13 @@
 import {
-  AppBskyActorDefs,
-  AppBskyGraphDefs,
-  AppBskyGraphGetList,
-  BskyAgent,
+  type AppBskyActorDefs,
+  type AppBskyGraphDefs,
+  type AppBskyGraphGetList,
+  type BskyAgent,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
-  QueryKey,
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
   useInfiniteQuery,
   useQuery,
 } from '@tanstack/react-query'
@@ -100,21 +100,16 @@ export function* findAllProfilesInQueryData(
     queryKey: [RQKEY_ROOT],
   })
   for (const [_queryKey, queryData] of queryDatas) {
-    if (!queryData) {
+    if (!queryData?.pages) {
       continue
     }
-    for (const [_queryKey, queryData] of queryDatas) {
-      if (!queryData?.pages) {
-        continue
+    for (const page of queryData?.pages) {
+      if (page.list.creator.did === did) {
+        yield page.list.creator
       }
-      for (const page of queryData?.pages) {
-        if (page.list.creator.did === did) {
-          yield page.list.creator
-        }
-        for (const item of page.items) {
-          if (item.subject.did === did) {
-            yield item.subject
-          }
+      for (const item of page.items) {
+        if (item.subject.did === did) {
+          yield item.subject
         }
       }
     }
diff --git a/src/state/queries/messages/actor-declaration.ts b/src/state/queries/messages/actor-declaration.ts
index 34fb10935..a5adb39d9 100644
--- a/src/state/queries/messages/actor-declaration.ts
+++ b/src/state/queries/messages/actor-declaration.ts
@@ -1,4 +1,4 @@
-import {AppBskyActorDefs} from '@atproto/api'
+import {type AppBskyActorDefs} from '@atproto/api'
 import {useMutation, useQueryClient} from '@tanstack/react-query'
 
 import {logger} from '#/logger'
@@ -19,7 +19,7 @@ export function useUpdateActorDeclaration({
   return useMutation({
     mutationFn: async (allowIncoming: 'all' | 'none' | 'following') => {
       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,
         collection: 'chat.bsky.actor.declaration',
         rkey: 'self',
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index fce7802bc..6010f11b4 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -18,30 +18,30 @@
 
 import {useCallback, useEffect, useMemo, useRef} from 'react'
 import {
-  AppBskyActorDefs,
+  type AppBskyActorDefs,
   AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
   moderatePost,
 } from '@atproto/api'
 import {
-  InfiniteData,
-  QueryClient,
-  QueryKey,
+  type InfiniteData,
+  type QueryClient,
+  type QueryKey,
   useInfiniteQuery,
   useQueryClient,
 } from '@tanstack/react-query'
 
+import {useModerationOpts} from '#/state/preferences/moderation-opts'
+import {STALE} from '#/state/queries'
 import {useAgent} from '#/state/session'
 import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
-import {useModerationOpts} from '../../preferences/moderation-opts'
-import {STALE} from '..'
 import {
   didOrHandleUriMatches,
   embedViewRecordToPostView,
   getEmbeddedPost,
 } from '../util'
-import {FeedPage} from './types'
+import {type FeedPage} from './types'
 import {useUnreadNotificationsApi} from './unread'
 import {fetchPage} from './util'
 
diff --git a/src/state/queries/notifications/types.ts b/src/state/queries/notifications/types.ts
index e05715f77..a7b837086 100644
--- a/src/state/queries/notifications/types.ts
+++ b/src/state/queries/notifications/types.ts
@@ -48,6 +48,7 @@ type OtherNotificationType =
   | 'unverified'
   | 'like-via-repost'
   | 'repost-via-repost'
+  | 'subscribed-post'
   | 'unknown'
 
 type FeedNotificationBase = {
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 007f65cc7..faccd8087 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -28,6 +28,7 @@ const GROUPABLE_REASONS = [
   'follow',
   'like-via-repost',
   'repost-via-repost',
+  'subscribed-post',
 ]
 const MS_1HR = 1e3 * 60 * 60
 const MS_2DAY = MS_1HR * 48
@@ -144,7 +145,8 @@ export function groupNotifications(
           Math.abs(ts2 - ts) < MS_2DAY &&
           notif.reason === groupedNotif.notification.reason &&
           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 =
             notif.reason === 'follow' && notif.author.viewer?.following
@@ -252,7 +254,8 @@ function toKnownType(
     notif.reason === 'verified' ||
     notif.reason === 'unverified' ||
     notif.reason === 'like-via-repost' ||
-    notif.reason === 'repost-via-repost'
+    notif.reason === 'repost-via-repost' ||
+    notif.reason === 'subscribed-post'
   ) {
     return notif.reason as NotificationType
   }
@@ -263,7 +266,12 @@ function getSubjectUri(
   type: NotificationType,
   notif: AppBskyNotificationListNotifications.Notification,
 ): string | undefined {
-  if (type === 'reply' || type === 'quote' || type === 'mention') {
+  if (
+    type === 'reply' ||
+    type === 'quote' ||
+    type === 'mention' ||
+    type === 'subscribed-post'
+  ) {
     return notif.uri
   } else if (
     type === 'post-like' ||
diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts
index a44ffa4c5..1947f857f 100644
--- a/src/state/queries/nuxs/definitions.ts
+++ b/src/state/queries/nuxs/definitions.ts
@@ -6,6 +6,7 @@ export enum Nux {
   NeueTypography = 'NeueTypography',
   ExploreInterestsCard = 'ExploreInterestsCard',
   InitialVerificationAnnouncement = 'InitialVerificationAnnouncement',
+  ActivitySubscriptions = 'ActivitySubscriptions',
 }
 
 export const nuxNames = new Set(Object.values(Nux))
@@ -23,10 +24,15 @@ export type AppNux = BaseNux<
       id: Nux.InitialVerificationAnnouncement
       data: undefined
     }
+  | {
+      id: Nux.ActivitySubscriptions
+      data: undefined
+    }
 >
 
 export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
   [Nux.NeueTypography]: undefined,
   [Nux.ExploreInterestsCard]: undefined,
   [Nux.InitialVerificationAnnouncement]: undefined,
+  [Nux.ActivitySubscriptions]: undefined,
 }
diff --git a/src/state/queries/post-feed.ts b/src/state/queries/post-feed.ts
index 920892924..361081e67 100644
--- a/src/state/queries/post-feed.ts
+++ b/src/state/queries/post-feed.ts
@@ -24,6 +24,7 @@ import {HomeFeedAPI} from '#/lib/api/feed/home'
 import {LikesFeedAPI} from '#/lib/api/feed/likes'
 import {ListFeedAPI} from '#/lib/api/feed/list'
 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 {aggregateUserInterests} from '#/lib/api/feed/utils'
 import {FeedTuner, type FeedTunerFn} from '#/lib/api/feed-manip'
@@ -53,6 +54,7 @@ export type AuthorFilter =
   | 'posts_with_video'
 type FeedUri = string
 type ListUri = string
+type PostsUriList = string
 
 export type FeedDescriptor =
   | 'following'
@@ -60,6 +62,7 @@ export type FeedDescriptor =
   | `feedgen|${FeedUri}`
   | `likes|${ActorDid}`
   | `list|${ListUri}`
+  | `posts|${PostsUriList}`
   | 'demo'
 export interface FeedParams {
   mergeFeedEnabled?: boolean
@@ -488,6 +491,9 @@ function createApi({
   } else if (feedDesc.startsWith('list')) {
     const [_, list] = feedDesc.split('|')
     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') {
     return new DemoFeedAPI({agent})
   } else {
diff --git a/src/storage/hooks/activity-subscriptions-nudged.ts b/src/storage/hooks/activity-subscriptions-nudged.ts
new file mode 100644
index 000000000..0e9c1c39c
--- /dev/null
+++ b/src/storage/hooks/activity-subscriptions-nudged.ts
@@ -0,0 +1,8 @@
+import {device, useStorage} from '#/storage'
+
+export function useActivitySubscriptionsNudged() {
+  const [activitySubscriptionsNudged = false, setActivitySubscriptionsNudged] =
+    useStorage(device, ['activitySubscriptionsNudged'])
+
+  return [activitySubscriptionsNudged, setActivitySubscriptionsNudged] as const
+}
diff --git a/src/storage/schema.ts b/src/storage/schema.ts
index 7430532a9..19c31834b 100644
--- a/src/storage/schema.ts
+++ b/src/storage/schema.ts
@@ -11,6 +11,7 @@ export type Device = {
   trendingBetaEnabled: boolean
   devMode: boolean
   demoMode: boolean
+  activitySubscriptionsNudged?: boolean
 }
 
 export type Account = {
diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx
index 85f67919a..89e2d20e7 100644
--- a/src/view/com/notifications/NotificationFeedItem.tsx
+++ b/src/view/com/notifications/NotificationFeedItem.tsx
@@ -52,6 +52,7 @@ import {TimeElapsed} from '#/view/com/util/TimeElapsed'
 import {PreviewableUserAvatar, UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, platform, useTheme} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
+import {BellRinging_Filled_Corner0_Rounded as BellRingingIcon} from '#/components/icons/BellRinging'
 import {
   ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon,
   ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon,
@@ -114,7 +115,9 @@ let NotificationFeedItem = ({
       case 'unverified': {
         return makeProfileLink(item.notification.author)
       }
-      case 'reply': {
+      case 'reply':
+      case 'mention':
+      case 'quote': {
         const uripReply = new AtUri(item.notification.uri)
         return `/profile/${uripReply.host}/post/${uripReply.rkey}`
       }
@@ -126,6 +129,13 @@ let NotificationFeedItem = ({
         }
         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 ''
@@ -155,7 +165,10 @@ let NotificationFeedItem = ({
         href: makeProfileLink(author),
         moderation: moderateProfile(author, moderationOpts),
       })) || []),
-    ]
+    ].filter(
+      (author, index, arr) =>
+        arr.findIndex(au => au.profile.did === author.profile.did) === index,
+    )
   }, [item, moderationOpts])
 
   const niceTimestamp = niceDate(i18n, item.notification.indexedAt)
@@ -503,6 +516,42 @@ let NotificationFeedItem = ({
       <Trans>{firstAuthorLink} reposted your repost</Trans>
     )
     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 {
     return null
   }
@@ -613,7 +662,8 @@ let NotificationFeedItem = ({
             {item.type === 'post-like' ||
             item.type === '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]}>
                 <AdditionalPostText post={item.subject} />
               </View>
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index 30ebbf2c2..c1f804203 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -96,7 +96,8 @@ let PostMeta = (opts: PostMetaOpts): React.ReactNode => {
                 a.font_bold,
                 t.atoms.text,
                 a.leading_tight,
-                {maxWidth: '70%', flexShrink: 0},
+                a.flex_shrink_0,
+                {maxWidth: '70%'},
               ]}>
               {forceLTR(
                 sanitizeDisplayName(