about summary refs log tree commit diff
path: root/src/screens/Settings/NotificationSettings
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/screens/Settings/NotificationSettings
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/screens/Settings/NotificationSettings')
-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
3 files changed, 280 insertions, 22 deletions
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`,