about summary refs log tree commit diff
path: root/src/components/activity-notifications
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-07-02 00:36:04 +0300
committerGitHub <noreply@github.com>2025-07-01 14:36:04 -0700
commitbc072570d27e1f397406daea355570f5aec95647 (patch)
tree0d698c0bababd9b5e221df763a1ab15744ebdb71 /src/components/activity-notifications
parent8f9a8ddce022e328b07b793c3f1500e1c423ef73 (diff)
downloadvoidsky-bc072570d27e1f397406daea355570f5aec95647.tar.zst
Activity notification settings (#8485)
Co-authored-by: Eric Bailey <git@esb.lol>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: hailey <me@haileyok.com>
Diffstat (limited to 'src/components/activity-notifications')
-rw-r--r--src/components/activity-notifications/SubscribeProfileButton.tsx89
-rw-r--r--src/components/activity-notifications/SubscribeProfileDialog.tsx309
2 files changed, 398 insertions, 0 deletions
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}
+}