about summary refs log tree commit diff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-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
10 files changed, 732 insertions, 63 deletions
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',
+})