about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--assets/icons/bellPlus_stroke2_corner0_rounded.svg1
-rw-r--r--assets/icons/bellRinging_filled_corner0_rounded.svg1
-rw-r--r--assets/images/activity_notifications_announcement.webpbin0 -> 22058 bytes
-rw-r--r--bskyweb/cmd/bskyweb/server.go2
-rw-r--r--modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt4
-rw-r--r--package.json4
-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
-rw-r--r--yarn.lock174
53 files changed, 1810 insertions, 288 deletions
diff --git a/assets/icons/bellPlus_stroke2_corner0_rounded.svg b/assets/icons/bellPlus_stroke2_corner0_rounded.svg
new file mode 100644
index 000000000..7e3349427
--- /dev/null
+++ b/assets/icons/bellPlus_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a1 1 0 0 1 0 2 5.85 5.85 0 0 0-5.802 5.08L5.143 17h13.715l-.382-2.868-.01-.102a1 1 0 0 1 1.973-.262l.02.1.532 4a1 1 0 0 1-.99 1.132h-3.357c-.905 1.747-2.606 3-4.644 3s-3.74-1.253-4.643-3H4a1 1 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.61.637 1.397 1 2.22 1s1.611-.363 2.22-1H9.78ZM17 2.5a1 1 0 0 1 1 1V6h2.5a1 1 0 0 1 0 2H18v2.5a1 1 0 0 1-2 0V8h-2.5a1 1 0 1 1 0-2H16V3.5a1 1 0 0 1 1-1Z"/></svg>
diff --git a/assets/icons/bellRinging_filled_corner0_rounded.svg b/assets/icons/bellRinging_filled_corner0_rounded.svg
new file mode 100644
index 000000000..67be5d99d
--- /dev/null
+++ b/assets/icons/bellRinging_filled_corner0_rounded.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M12 2a7.854 7.854 0 0 1 7.784 6.815l1.207 9.053a1 1 0 0 1-.99 1.132h-3.354c-.904 1.748-2.608 3-4.647 3-2.038 0-3.742-1.252-4.646-3H4a1.002 1.002 0 0 1-.991-1.132l1.207-9.053A7.85 7.85 0 0 1 12 2ZM9.78 19c.608.637 1.398 1 2.221 1s1.613-.363 2.222-1H9.779ZM3.193 2.104a1 1 0 0 1 1.53 1.288A9.5 9.5 0 0 0 2.72 7.464a1 1 0 0 1-1.954-.427 11.46 11.46 0 0 1 2.428-4.933Zm16.205-.122a1 1 0 0 1 1.409.122 11.5 11.5 0 0 1 2.429 4.933 1 1 0 0 1-1.954.427 9.5 9.5 0 0 0-2.006-4.072 1 1 0 0 1 .122-1.41Z"/></svg>
diff --git a/assets/images/activity_notifications_announcement.webp b/assets/images/activity_notifications_announcement.webp
new file mode 100644
index 000000000..885d1b7d1
--- /dev/null
+++ b/assets/images/activity_notifications_announcement.webp
Binary files differdiff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index ef796920d..755f2fa3f 100644
--- a/bskyweb/cmd/bskyweb/server.go
+++ b/bskyweb/cmd/bskyweb/server.go
@@ -258,6 +258,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/feeds", server.WebGeneric)
 	e.GET("/notifications", server.WebGeneric)
 	e.GET("/notifications/settings", server.WebGeneric)
+	e.GET("/notifications/activity", server.WebGeneric)
 	e.GET("/lists", server.WebGeneric)
 	e.GET("/moderation", server.WebGeneric)
 	e.GET("/moderation/modlists", server.WebGeneric)
@@ -275,6 +276,7 @@ func serve(cctx *cli.Context) error {
 	e.GET("/settings/appearance", server.WebGeneric)
 	e.GET("/settings/account", server.WebGeneric)
 	e.GET("/settings/privacy-and-security", server.WebGeneric)
+	e.GET("/settings/privacy-and-security/activity", server.WebGeneric)
 	e.GET("/settings/content-and-media", server.WebGeneric)
 	e.GET("/settings/interests", server.WebGeneric)
 	e.GET("/settings/about", server.WebGeneric)
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
index 9fdfcfd89..4f8a6b892 100644
--- a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -45,10 +45,10 @@ class BackgroundNotificationHandler(
   private fun mutateWithOtherReason(remoteMessage: RemoteMessage) {
     // If oreo or higher
     if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
-      // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost"
+      // If one of "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post"
       // assign to it's eponymous channel. otherwise do nothing, let expo handle it
       when (remoteMessage.data["reason"]) {
-        "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost" -> {
+        "like", "repost", "follow", "mention", "reply", "quote", "like-via-repost", "repost-via-repost", "subscribed-post" -> {
           remoteMessage.data["channelId"] = remoteMessage.data["reason"]
         }
       }
diff --git a/package.json b/package.json
index 7c1c3dee9..aae35f96a 100644
--- a/package.json
+++ b/package.json
@@ -69,7 +69,7 @@
     "icons:optimize": "svgo -f ./assets/icons"
   },
   "dependencies": {
-    "@atproto/api": "^0.15.16",
+    "@atproto/api": "^0.15.21",
     "@bitdrift/react-native": "^0.6.8",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -218,7 +218,7 @@
     "zod": "^3.20.2"
   },
   "devDependencies": {
-    "@atproto/dev-env": "^0.3.144",
+    "@atproto/dev-env": "^0.3.150",
     "@babel/core": "^7.26.0",
     "@babel/preset-env": "^7.26.0",
     "@babel/runtime": "^7.26.0",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 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(
diff --git a/yarn.lock b/yarn.lock
index 3280cbb9b..90db949d2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -63,10 +63,10 @@
     "@atproto/xrpc" "^0.7.0"
     "@atproto/xrpc-server" "^0.8.0"
 
-"@atproto/api@^0.15.16":
-  version "0.15.16"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.16.tgz#1962e7067e03a661e17c3164874596ef1e7ed7ad"
-  integrity sha512-ZNBrzBg2l0lHreKik1lJn8lrhAktwlY8NUPBU/hO9dwjAnDHQTiSzNFZt65dp9djmqZ75sX/VJ+heNuaJBvnhQ==
+"@atproto/api@^0.15.21":
+  version "0.15.21"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.21.tgz#6cd450c49dc30ea7baca4905b9046abf69f9c1bd"
+  integrity sha512-/VsikzVqIjNrdCk3eoJAleNcPUAGOLW8GCU9ymQMyGg1bBOCDb2Gl4eCqvhJ7Zd/UUyU5o8bh2YwLsY8/ikkeA==
   dependencies:
     "@atproto/common-web" "^0.4.2"
     "@atproto/lexicon" "^0.4.11"
@@ -77,14 +77,14 @@
     tlds "^1.234.0"
     zod "^3.23.8"
 
-"@atproto/aws@^0.2.22":
-  version "0.2.22"
-  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.22.tgz#14a664c06e3569945e4ab143d3a8a03400c7d1de"
-  integrity sha512-xZ+0/zHHmpgzdLJGTDkFl5Wd39Wm5MyyMLdGYSzyt0wGTBmH6Ktp7ZgR8rmQVNYN1+VkMcdClAiNhg+BSH3mRw==
+"@atproto/aws@^0.2.24":
+  version "0.2.24"
+  resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.24.tgz#c8e7a804710d70be3aa2fa292c1ece4c05127891"
+  integrity sha512-4XZQGitPJR56tFt1bzPJKOqp3vTVcfVsEAFo9FGWp7Es+jj742aVgfWEe64O0VoZp3ZTiD7XhwsLJArz7NJTlQ==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
-    "@atproto/repo" "^0.8.2"
+    "@atproto/repo" "^0.8.4"
     "@aws-sdk/client-cloudfront" "^3.261.0"
     "@aws-sdk/client-kms" "^3.196.0"
     "@aws-sdk/client-s3" "^3.224.0"
@@ -94,21 +94,21 @@
     multiformats "^9.9.0"
     uint8arrays "3.0.0"
 
-"@atproto/bsky@^0.0.161":
-  version "0.0.161"
-  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.161.tgz#671280c1d40f5c4eb0cc31d338a9e950acbf0ce0"
-  integrity sha512-L4uzadjt+oyVq3+W7rc1A+X2DyZDsTfeSD15w7k6+6JzICp32qavDuVjut3CIBqXCt7ykvSDujApyLsB/lcWJQ==
+"@atproto/bsky@^0.0.167":
+  version "0.0.167"
+  resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.167.tgz#583eb404ef4de409e34d7c2485bf325e5d1f3ff0"
+  integrity sha512-VLgaVsx0fYeoXcFHP1KM6joda9Ovhb7LsE3JdES6+hhsAF74DFwW57mVzRfYhy1bwWn/m9poUMs1RkCjOR9ZJA==
   dependencies:
     "@atproto-labs/fetch-node" "0.1.9"
     "@atproto-labs/xrpc-utils" "0.0.16"
-    "@atproto/api" "^0.15.16"
+    "@atproto/api" "^0.15.21"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/did" "^0.1.5"
     "@atproto/identity" "^0.4.8"
     "@atproto/lexicon" "^0.4.11"
-    "@atproto/repo" "^0.8.2"
-    "@atproto/sync" "^0.1.26"
+    "@atproto/repo" "^0.8.4"
+    "@atproto/sync" "^0.1.28"
     "@atproto/syntax" "^0.4.0"
     "@atproto/xrpc-server" "^0.8.0"
     "@bufbuild/protobuf" "^1.5.0"
@@ -218,21 +218,21 @@
     "@noble/hashes" "^1.6.1"
     uint8arrays "3.0.0"
 
-"@atproto/dev-env@^0.3.144":
-  version "0.3.144"
-  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.144.tgz#cd2949ff870ca4cde23b4c377b08740a2e64151f"
-  integrity sha512-ND0oGp7itSnXxlAHlFxYjGFyCcu0f4eSucImVtKRxTcW8UeyyTtJcQP8OyNvtC8j13YjbW124r0g25Wlm0j9XQ==
+"@atproto/dev-env@^0.3.150":
+  version "0.3.150"
+  resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.150.tgz#6443206352398be1e3dd8bcfe980e7a21d2cd93a"
+  integrity sha512-LOujaEmOVBCxSnKQqpJb238fe5vYGIgmTA+OMEFH3kZb+6Y6UXfW2Vhs79tP0DiX0VyoXwib/7PH3Lp5cC/ZFQ==
   dependencies:
-    "@atproto/api" "^0.15.16"
-    "@atproto/bsky" "^0.0.161"
+    "@atproto/api" "^0.15.21"
+    "@atproto/bsky" "^0.0.167"
     "@atproto/bsync" "^0.0.20"
     "@atproto/common-web" "^0.4.2"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
     "@atproto/lexicon" "^0.4.11"
-    "@atproto/ozone" "^0.1.121"
-    "@atproto/pds" "^0.4.150"
-    "@atproto/sync" "^0.1.26"
+    "@atproto/ozone" "^0.1.126"
+    "@atproto/pds" "^0.4.156"
+    "@atproto/sync" "^0.1.28"
     "@atproto/syntax" "^0.4.0"
     "@atproto/xrpc-server" "^0.8.0"
     "@did-plc/lib" "^0.0.1"
@@ -259,18 +259,18 @@
     "@atproto/common-web" "^0.4.2"
     "@atproto/crypto" "^0.4.4"
 
-"@atproto/jwk-jose@0.1.8":
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.8.tgz#2dc8ad2cc900e7bc231add293f6518b06dc017ec"
-  integrity sha512-aoU2Q0GpIl388KhCcv9YvAxNscALUv3xzLq5gjVPdJ+zmqw94nGZNcjiNvpnbfS+VQM9e2DrrTuwmDXnxfrrSA==
+"@atproto/jwk-jose@0.1.9":
+  version "0.1.9"
+  resolved "https://registry.yarnpkg.com/@atproto/jwk-jose/-/jwk-jose-0.1.9.tgz#bd4a899ea2d497808300c40106795f5645c01f75"
+  integrity sha512-HT9GcUe6htDxI5OSYXWdeS6QZ9lpuDDvJk508ppi8a48E/1f8eumoM0QhgbFRF9IKAnnFrtnZDOAvljQzFKwwQ==
   dependencies:
-    "@atproto/jwk" "0.3.0"
+    "@atproto/jwk" "0.4.0"
     jose "^5.2.0"
 
-"@atproto/jwk@0.3.0":
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.3.0.tgz#275fa676f6b5988ddedf4ee0475dd285de9b831b"
-  integrity sha512-MIAXyNMGu1tCNHjqW/8jqfE/wgWCIoK2cJ0mR6UxwhNPvkbe35TcpRYJdtQu/E6MUd7TziyDBa/GO4dKAiePhQ==
+"@atproto/jwk@0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@atproto/jwk/-/jwk-0.4.0.tgz#f32265be172492c38434c556a124b954f249cee8"
+  integrity sha512-tvp4iZrzqEzKCeTOKz50/o6WdsZzOuWmWjF6On5QAp04fLwLpsFu2Hixgx/lA1KBO0O4sns7YSGcAqSSX6Rdog==
   dependencies:
     multiformats "^9.9.0"
     zod "^3.23.8"
@@ -286,32 +286,32 @@
     multiformats "^9.9.0"
     zod "^3.23.8"
 
-"@atproto/oauth-provider-api@0.1.4":
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.4.tgz#a775182e3648dc693a04e3cb604eb62cd9ddfd8c"
-  integrity sha512-3PRrf0gTAVMCETjtIH/3AaQaHBDbjsRBc/OYrlWBZ9IPplchBXtQGH/KcnjE4kK2Ef8p45qQSl3dNWg3EXsbHQ==
+"@atproto/oauth-provider-api@0.1.6":
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-api/-/oauth-provider-api-0.1.6.tgz#769a70caaac9b5144f9f867518523d1568a6b47c"
+  integrity sha512-4Q6ZCnTmmdiWiA+KMrfbZmqjxTSgMe+YE68+3RccwOCIgPt171TiDHGKIayep9n1RDnuucVQoqvVXOT4kmAsjw==
   dependencies:
-    "@atproto/jwk" "0.3.0"
-    "@atproto/oauth-types" "0.3.0"
+    "@atproto/jwk" "0.4.0"
+    "@atproto/oauth-types" "0.4.0"
 
-"@atproto/oauth-provider-frontend@0.1.8":
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.8.tgz#21d944566c63f54524f239a10f7c65d150982f40"
-  integrity sha512-uqfHv+n2xq7vTpuBP1Red7PhpaAbbJbwSbRsSfplJQ16XmF5NCMU8dHGCGRTEHngLZ9UquuIefN3w1QTrNzD0w==
+"@atproto/oauth-provider-frontend@0.1.10":
+  version "0.1.10"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-frontend/-/oauth-provider-frontend-0.1.10.tgz#d7176819d0ae1401ca5d70f7afec253621901a79"
+  integrity sha512-bOFpi5OIxWv4Q9ci1+PAXEzIZaiu5inepC7pRFYqgqgLoCO0MWH/5Qkn/f6jMpDwPdtBqAiPg9tjE7E3le6NJA==
   optionalDependencies:
-    "@atproto/oauth-provider-api" "0.1.4"
+    "@atproto/oauth-provider-api" "0.1.6"
 
-"@atproto/oauth-provider-ui@0.1.9":
-  version "0.1.9"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.9.tgz#8c43a1affa94ecb537072e6d569b8a24cdd42e72"
-  integrity sha512-a6/VAeQWRMxpgnqo/TuqXg3EW2tO68jLh8Mv1uyV1NiZbT7fNlgkII/djIl3fLoEa95I3p236NZxjhKELSBbGg==
+"@atproto/oauth-provider-ui@0.1.11":
+  version "0.1.11"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider-ui/-/oauth-provider-ui-0.1.11.tgz#cb6194ac0b93f1d4b5d6717f80c55a3a20a8c690"
+  integrity sha512-9fflyDt4Y3RDJIfbonxVeMbQtLLQrkQSDhWhPXp9xbZ/uYBddaAw+svBfFoMY7dxdlJbQeUPobsUctEm3qAILg==
   optionalDependencies:
-    "@atproto/oauth-provider-api" "0.1.4"
+    "@atproto/oauth-provider-api" "0.1.6"
 
-"@atproto/oauth-provider@^0.9.1":
-  version "0.9.1"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.1.tgz#0147b75d1ad444455159f0a687ce87b3b49a2894"
-  integrity sha512-2Gm3jv45cGLmUQV0C4/orCJBsHu4wajy+JTN9f/ATX3vvjnFtAd/1GRvAMKDGXtdF7VIjNFlD+4lqhoDxYJpng==
+"@atproto/oauth-provider@^0.9.3":
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.9.3.tgz#047b2e520e5cf127385adddc1dca47207b0ca113"
+  integrity sha512-TAhsCYDB/1twEA1vqjLAz7lxKI8W59eNs239MujE35Cc9l4lRHyMopoFv5JmgNnxDvloB5l6RxpTbXVC6wnKpQ==
   dependencies:
     "@atproto-labs/fetch" "0.2.3"
     "@atproto-labs/fetch-node" "0.1.9"
@@ -320,12 +320,12 @@
     "@atproto-labs/simple-store-memory" "0.1.3"
     "@atproto/common" "^0.4.11"
     "@atproto/did" "0.1.5"
-    "@atproto/jwk" "0.3.0"
-    "@atproto/jwk-jose" "0.1.8"
-    "@atproto/oauth-provider-api" "0.1.4"
-    "@atproto/oauth-provider-frontend" "0.1.8"
-    "@atproto/oauth-provider-ui" "0.1.9"
-    "@atproto/oauth-types" "0.3.0"
+    "@atproto/jwk" "0.4.0"
+    "@atproto/jwk-jose" "0.1.9"
+    "@atproto/oauth-provider-api" "0.1.6"
+    "@atproto/oauth-provider-frontend" "0.1.10"
+    "@atproto/oauth-provider-ui" "0.1.11"
+    "@atproto/oauth-types" "0.4.0"
     "@atproto/syntax" "0.4.0"
     "@hapi/accept" "^6.0.3"
     "@hapi/address" "^5.1.1"
@@ -339,20 +339,20 @@
     jose "^5.2.0"
     zod "^3.23.8"
 
-"@atproto/oauth-types@0.3.0":
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.3.0.tgz#8d49d939486ac281bc13d0b1fe4462b7e519fdf0"
-  integrity sha512-ptfsJARKODXfuOoDQag4a6PpEkDEj4Urz3jOmnQZy2YspPc/TNm1o0HglU0YehELv1vfhh9gEz40BJztPPhiLA==
+"@atproto/oauth-types@0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.4.0.tgz#fb110717dd1e8593adffc6eaa85e7ab4f0713740"
+  integrity sha512-FrRH9JsPw9H4JxfPDrbrI+pB102tbHTygajfHay7xwz78HPOjSbWPRgWW2hYS4w8vDYdB3PYbBj1jPoKetW7LA==
   dependencies:
-    "@atproto/jwk" "0.3.0"
+    "@atproto/jwk" "0.4.0"
     zod "^3.23.8"
 
-"@atproto/ozone@^0.1.121":
-  version "0.1.121"
-  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.121.tgz#309b7e876f3b598ed4e79bb5a79e2346931588fe"
-  integrity sha512-kc3NxiXSPqQmWz8yXlV5cFnZ469ViQd0AexEMw467AcB8ikK1WSxhLsa1EiNAQuLOOpyeXSmAKGAUFHzSOIMpw==
+"@atproto/ozone@^0.1.126":
+  version "0.1.126"
+  resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.126.tgz#a4502121b9732a494a8b25a04be89b7eb0a4e2dd"
+  integrity sha512-h1yP1NArjjHlOam9wamGIUSrG9tGynkZ0+Y6t21u7dwrg1o/TRpXSXemCYZhtz3zqdd4Yu5VyavoWPtEFdr+rQ==
   dependencies:
-    "@atproto/api" "^0.15.16"
+    "@atproto/api" "^0.15.21"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
@@ -377,21 +377,21 @@
     undici "^6.14.1"
     ws "^8.12.0"
 
-"@atproto/pds@^0.4.150":
-  version "0.4.150"
-  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.150.tgz#45686b05b8ed46e265efa5231ab16e6eda72a8e8"
-  integrity sha512-CPT6H2uDTe4ZAyxQbws2dIlmdFFf6GQGwMc0OE3kI1wBBaLHprpexjM2Gd4ObtYNxGOOV0fwoCDAth8qqZ4XVw==
+"@atproto/pds@^0.4.156":
+  version "0.4.156"
+  resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.156.tgz#1815ced4ab8b51cf9fe9a5712cd136a0b1d82392"
+  integrity sha512-/8j/ihTLRhCI1sxkEvs2kuX4ehPKvsnwDxhmhdVvYqbKrjmGRTsDIZDV1K7dVFcYdCypOEPXsgTReh2lVhcC8w==
   dependencies:
     "@atproto-labs/fetch-node" "0.1.9"
     "@atproto-labs/xrpc-utils" "0.0.16"
-    "@atproto/api" "^0.15.16"
-    "@atproto/aws" "^0.2.22"
+    "@atproto/api" "^0.15.21"
+    "@atproto/aws" "^0.2.24"
     "@atproto/common" "^0.4.11"
     "@atproto/crypto" "^0.4.4"
     "@atproto/identity" "^0.4.8"
     "@atproto/lexicon" "^0.4.11"
-    "@atproto/oauth-provider" "^0.9.1"
-    "@atproto/repo" "^0.8.2"
+    "@atproto/oauth-provider" "^0.9.3"
+    "@atproto/repo" "^0.8.4"
     "@atproto/syntax" "^0.4.0"
     "@atproto/xrpc" "^0.7.0"
     "@atproto/xrpc-server" "^0.8.0"
@@ -424,10 +424,10 @@
     undici "^6.19.8"
     zod "^3.23.8"
 
-"@atproto/repo@^0.8.2":
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.2.tgz#7953cb2c637c94505da76f74a784b2aae050c204"
-  integrity sha512-lP0g5Uw3TUC2Tc7te8YKCpRoIhBYI+Uvn11fupGEaMcMjgLdYtB0Kc0AiqWXF42KqlBG9dAEoJITi2GRzDNHUg==
+"@atproto/repo@^0.8.4":
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.8.4.tgz#f6a1b4bce8cf86cd1825069f9cd2916a5f86e774"
+  integrity sha512-WgyARo6UcOnhbRsRVuNjXOH5MPTTHVDsaIavPeQl5erq5foE/pQKC7B7FLTJmhpC6GPZHJ5M2doAyXRXv5UHGA==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/common-web" "^0.4.2"
@@ -439,15 +439,15 @@
     varint "^6.0.0"
     zod "^3.23.8"
 
-"@atproto/sync@^0.1.26":
-  version "0.1.26"
-  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.26.tgz#6be2876be612d9cd704452598ee679b2e912cfe3"
-  integrity sha512-bpUIajtPrE3RgFW8mIfrI4EM/LJ4JjQhI5fsqc78zCHZawuflpllf1aH70roDWWiskMWoiLWnVRxdYXdeEgbXA==
+"@atproto/sync@^0.1.28":
+  version "0.1.28"
+  resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.28.tgz#7c5c469dd899b4be86e5d993af66646c71d63eaf"
+  integrity sha512-faCsOwcYQHxHmNWRPykV0hTccXaG15XoUMZozfmoFOKFSliTgDETTovSAVe05mNSBUvMWUGl8fdEwHRzq1Q8sA==
   dependencies:
     "@atproto/common" "^0.4.11"
     "@atproto/identity" "^0.4.8"
     "@atproto/lexicon" "^0.4.11"
-    "@atproto/repo" "^0.8.2"
+    "@atproto/repo" "^0.8.4"
     "@atproto/syntax" "^0.4.0"
     "@atproto/xrpc-server" "^0.8.0"
     multiformats "^9.9.0"