about summary refs log tree commit diff
path: root/src/screens/Settings/NotificationSettings
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2025-06-17 12:37:14 +0300
committerGitHub <noreply@github.com>2025-06-17 02:37:14 -0700
commit21989b558bd074bf84ac08c174d7a411fda1ffb7 (patch)
treef5f28510cf5a592b83bcfc581a57e992823eb402 /src/screens/Settings/NotificationSettings
parent7dc6bb57a6666db3e507630c13448487acceadc5 (diff)
downloadvoidsky-21989b558bd074bf84ac08c174d7a411fda1ffb7.tar.zst
Granular notification settings (#8484)
* add mockup screen

* add notification index screen

* add redirect screen

* upgrade sdk

* new icons

* add new screens

* make router typesafe, finish adding screens

* add routes to go server

* load settings

* push notif settings

* improve web

* fix lockfile lint

* no $type on preferences

* prompt to enable push notifications

* fix reply prefs

* space out options

* fix copy error

* Update RepostsOnRepostsNotificationSettings.tsx

* only send minimal diff to putPrefs

* fix yarn.lock

* Update Navigation.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* Update src/screens/Settings/NotificationSettings/index.tsx

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>

* add description to `syncOthers`

---------

Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com>
Diffstat (limited to 'src/screens/Settings/NotificationSettings')
-rw-r--r--src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx60
-rw-r--r--src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx65
-rw-r--r--src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx63
-rw-r--r--src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx68
-rw-r--r--src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx63
-rw-r--r--src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx60
-rw-r--r--src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx66
-rw-r--r--src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx63
-rw-r--r--src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx66
-rw-r--r--src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx34
-rw-r--r--src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx194
-rw-r--r--src/screens/Settings/NotificationSettings/index.tsx293
12 files changed, 1095 insertions, 0 deletions
diff --git a/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx
new file mode 100644
index 000000000..f726ab558
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/LikeNotificationSettings.tsx
@@ -0,0 +1,60 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Heart2_Stroke2_Corner0_Rounded as HeartIcon} from '#/components/icons/Heart2'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'LikeNotificationSettings'
+>
+export function LikeNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={HeartIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Likes</Trans>}
+              subtitleText={
+                <Trans>Get notifications when people like your posts.</Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls name="like" preference={preferences?.like} />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx
new file mode 100644
index 000000000..08a05d468
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/LikesOnRepostsNotificationSettings.tsx
@@ -0,0 +1,65 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon} from '#/components/icons/Heart2'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'LikesOnRepostsNotificationSettings'
+>
+export function LikesOnRepostsNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={LikeRepostIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Likes on your reposts</Trans>}
+              subtitleText={
+                <Trans>
+                  Get notifications when people like posts that you've reposted.
+                </Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="likeViaRepost"
+              preference={preferences?.likeViaRepost}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx
new file mode 100644
index 000000000..0a770157e
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/MentionNotificationSettings.tsx
@@ -0,0 +1,63 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {At_Stroke2_Corner2_Rounded as AtIcon} from '#/components/icons/At'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'MentionNotificationSettings'
+>
+export function MentionNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={AtIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Mentions</Trans>}
+              subtitleText={
+                <Trans>Get notifications when people mention you.</Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="mention"
+              preference={preferences?.mention}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx
new file mode 100644
index 000000000..a0fe65ecf
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/MiscellaneousNotificationSettings.tsx
@@ -0,0 +1,68 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'MiscellaneousNotificationSettings'
+>
+export function MiscellaneousNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={ShapesIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Everything else</Trans>}
+              subtitleText={
+                <Trans>
+                  Notifications for everything else, such as when someone joins
+                  via one of your starter packs.
+                </Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="starterpackJoined"
+              preference={preferences?.starterpackJoined}
+              syncOthers={['verified', 'unverified']}
+              allowDisableInApp={false}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx
new file mode 100644
index 000000000..dd603a52f
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/NewFollowerNotificationSettings.tsx
@@ -0,0 +1,63 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'NewFollowerNotificationSettings'
+>
+export function NewFollowerNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={PersonPlusIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>New followers</Trans>}
+              subtitleText={
+                <Trans>Get notifications when people follow you.</Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="follow"
+              preference={preferences?.follow}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx
new file mode 100644
index 000000000..afb3df90f
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/QuoteNotificationSettings.tsx
@@ -0,0 +1,60 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'QuoteNotificationSettings'
+>
+export function QuoteNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={CloseQuoteIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Quotes</Trans>}
+              subtitleText={
+                <Trans>Get notifications when people quote your posts.</Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls name="quote" preference={preferences?.quote} />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx
new file mode 100644
index 000000000..b3e7c6cff
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/ReplyNotificationSettings.tsx
@@ -0,0 +1,66 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'ReplyNotificationSettings'
+>
+export function ReplyNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={BubbleIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Replies</Trans>}
+              subtitleText={
+                <Trans>
+                  Get notifications when people reply to your posts.
+                </Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="reply"
+              preference={preferences?.reply}
+              allowDisableInApp={false}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx
new file mode 100644
index 000000000..aa9e4e32f
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/RepostNotificationSettings.tsx
@@ -0,0 +1,63 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {Repost_Stroke2_Corner2_Rounded as RepostIcon} from '#/components/icons/Repost'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'RepostNotificationSettings'
+>
+export function RepostNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={RepostIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Reposts</Trans>}
+              subtitleText={
+                <Trans>Get notifications when people repost your posts.</Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="repost"
+              preference={preferences?.repost}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx b/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx
new file mode 100644
index 000000000..13fec6168
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/RepostsOnRepostsNotificationSettings.tsx
@@ -0,0 +1,66 @@
+import {View} from 'react-native'
+import {Trans} from '@lingui/macro'
+
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+import {atoms as a} from '#/alf'
+import {Admonition} from '#/components/Admonition'
+import {RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon} from '#/components/icons/Repost'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+import {PreferenceControls} from './components/PreferenceControls'
+
+type Props = NativeStackScreenProps<
+  AllNavigatorParams,
+  'RepostsOnRepostsNotificationSettings'
+>
+export function RepostsOnRepostsNotificationSettingsScreen({}: Props) {
+  const {data: preferences, isError} = useNotificationSettingsQuery()
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          <SettingsList.Item style={[a.align_start]}>
+            <SettingsList.ItemIcon icon={RepostRepostIcon} />
+            <ItemTextWithSubtitle
+              bold
+              titleText={<Trans>Reposts of your reposts</Trans>}
+              subtitleText={
+                <Trans>
+                  Get notifications when people repost posts that you've
+                  reposted.
+                </Trans>
+              }
+            />
+          </SettingsList.Item>
+          {isError ? (
+            <View style={[a.px_lg, a.pt_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          ) : (
+            <PreferenceControls
+              name="repostViaRepost"
+              preference={preferences?.repostViaRepost}
+            />
+          )}
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx b/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx
new file mode 100644
index 000000000..217fc33b9
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/components/ItemTextWithSubtitle.tsx
@@ -0,0 +1,34 @@
+import {View} from 'react-native'
+
+import {atoms as a, useTheme} from '#/alf'
+import * as Skele from '#/components/Skeleton'
+import {Text} from '#/components/Typography'
+import * as SettingsList from '../../components/SettingsList'
+
+export function ItemTextWithSubtitle({
+  titleText,
+  subtitleText,
+  bold = false,
+  showSkeleton = false,
+}: {
+  titleText: React.ReactNode
+  subtitleText: React.ReactNode
+  bold?: boolean
+  showSkeleton?: boolean
+}) {
+  const t = useTheme()
+  return (
+    <View style={[a.flex_1, bold ? a.gap_xs : a.gap_2xs]}>
+      <SettingsList.ItemText style={bold && [a.font_bold, a.text_lg]}>
+        {titleText}
+      </SettingsList.ItemText>
+      {showSkeleton ? (
+        <Skele.Text style={[a.text_sm, {width: 120}]} />
+      ) : (
+        <Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
+          {subtitleText}
+        </Text>
+      )}
+    </View>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
new file mode 100644
index 000000000..336e08695
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/components/PreferenceControls.tsx
@@ -0,0 +1,194 @@
+import {useMemo} from 'react'
+import {View} from 'react-native'
+import {type AppBskyNotificationDefs} from '@atproto/api'
+import {type FilterablePreference} from '@atproto/api/dist/client/types/app/bsky/notification/defs'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
+import {atoms as a, platform, useTheme} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+import {Divider} from '../../components/SettingsList'
+
+export function PreferenceControls({
+  name,
+  syncOthers,
+  preference,
+  allowDisableInApp = true,
+}: {
+  name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
+  /**
+   * Keep other prefs in sync with `name`. For use in the "everything else" category
+   * which groups starterpack joins + verified + unverified notifications into a single toggle.
+   */
+  syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
+  preference?: AppBskyNotificationDefs.Preference | FilterablePreference
+  allowDisableInApp?: boolean
+}) {
+  if (!preference)
+    return (
+      <View style={[a.w_full, a.pt_5xl, a.align_center]}>
+        <Loader size="xl" />
+      </View>
+    )
+
+  return (
+    <Inner
+      name={name}
+      syncOthers={syncOthers}
+      preference={preference}
+      allowDisableInApp={allowDisableInApp}
+    />
+  )
+}
+
+export function Inner({
+  name,
+  syncOthers = [],
+  preference,
+  allowDisableInApp,
+}: {
+  name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
+  syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
+  preference: AppBskyNotificationDefs.Preference | FilterablePreference
+  allowDisableInApp: boolean
+}) {
+  const t = useTheme()
+  const {_} = useLingui()
+  const {mutate} = useNotificationSettingsUpdateMutation()
+
+  const channels = useMemo(() => {
+    const arr = []
+    if (preference.list) arr.push('list')
+    if (preference.push) arr.push('push')
+    return arr
+  }, [preference])
+
+  const onChangeChannels = (change: string[]) => {
+    const newPreference = {
+      ...preference,
+      list: change.includes('list'),
+      push: change.includes('push'),
+    } satisfies typeof preference
+
+    mutate({
+      [name]: newPreference,
+      ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
+    })
+  }
+
+  const onChangeFilter = ([change]: string[]) => {
+    if (change !== 'all' && change !== 'follows')
+      throw new Error('Invalid filter')
+
+    const newPreference = {
+      ...preference,
+      filter: change,
+    } satisfies typeof preference
+
+    mutate({
+      [name]: newPreference,
+      ...Object.fromEntries(syncOthers.map(key => [key, newPreference])),
+    })
+  }
+
+  return (
+    <View style={[a.px_xl, a.pt_md, a.gap_sm]}>
+      <Toggle.Group
+        type="checkbox"
+        label={_(`Select your preferred notification channels`)}
+        values={channels}
+        onChange={onChangeChannels}>
+        <View style={[a.gap_sm]}>
+          <Toggle.Item
+            label={_(msg`Receive push notifications`)}
+            name="push"
+            style={[
+              a.py_xs,
+              platform({
+                native: [a.justify_between],
+                web: [a.flex_row_reverse, a.gap_md],
+              }),
+            ]}>
+            <Toggle.LabelText
+              style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
+              <Trans>Push notifications</Trans>
+            </Toggle.LabelText>
+            <Toggle.Platform />
+          </Toggle.Item>
+          {allowDisableInApp && (
+            <Toggle.Item
+              label={_(msg`Receive in-app notifications`)}
+              name="list"
+              style={[
+                a.py_xs,
+                platform({
+                  native: [a.justify_between],
+                  web: [a.flex_row_reverse, a.gap_md],
+                }),
+              ]}>
+              <Toggle.LabelText
+                style={[t.atoms.text, a.font_normal, a.text_md, a.flex_1]}>
+                <Trans>In-app notifications</Trans>
+              </Toggle.LabelText>
+              <Toggle.Platform />
+            </Toggle.Item>
+          )}
+        </View>
+      </Toggle.Group>
+      {'filter' in preference && (
+        <>
+          <Divider />
+          <Text style={[a.font_bold, a.text_md]}>From</Text>
+          <Toggle.Group
+            type="radio"
+            label={_('Filter who you receive notifications from')}
+            values={[preference.filter]}
+            onChange={onChangeFilter}
+            disabled={channels.length === 0}>
+            <View style={[a.gap_sm]}>
+              <Toggle.Item
+                label={_(msg`Everyone`)}
+                name="all"
+                style={[
+                  a.flex_row,
+                  a.py_xs,
+                  platform({native: [a.gap_sm], web: [a.gap_md]}),
+                ]}>
+                <Toggle.Radio />
+                <Toggle.LabelText
+                  style={[
+                    channels.length > 0 && t.atoms.text,
+                    a.font_normal,
+                    a.text_md,
+                  ]}>
+                  <Trans>Everyone</Trans>
+                </Toggle.LabelText>
+              </Toggle.Item>
+              <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]}),
+                ]}>
+                <Toggle.Radio />
+                <Toggle.LabelText
+                  style={[
+                    channels.length > 0 && t.atoms.text,
+                    a.font_normal,
+                    a.text_md,
+                  ]}>
+                  <Trans>People I follow</Trans>
+                </Toggle.LabelText>
+              </Toggle.Item>
+            </View>
+          </Toggle.Group>
+        </>
+      )}
+    </View>
+  )
+}
diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx
new file mode 100644
index 000000000..a4f6dede0
--- /dev/null
+++ b/src/screens/Settings/NotificationSettings/index.tsx
@@ -0,0 +1,293 @@
+import {useEffect} from 'react'
+import {Linking, View} from 'react-native'
+import * as Notification from 'expo-notifications'
+import {type AppBskyNotificationDefs} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {useAppState} from '#/lib/hooks/useAppState'
+import {
+  type AllNavigatorParams,
+  type NativeStackScreenProps,
+} from '#/lib/routes/types'
+import {isAndroid, isIOS, isWeb} from '#/platform/detection'
+import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
+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 {Bubble_Stroke2_Corner2_Rounded as BubbleIcon} from '#/components/icons/Bubble'
+import {Haptic_Stroke2_Corner2_Rounded as HapticIcon} from '#/components/icons/Haptic'
+import {
+  Heart2_Stroke2_Corner0_Rounded as HeartIcon,
+  LikeRepost_Stroke2_Corner2_Rounded as LikeRepostIcon,
+} from '#/components/icons/Heart2'
+import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
+import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
+import {
+  Repost_Stroke2_Corner2_Rounded as RepostIcon,
+  RepostRepost_Stroke2_Corner2_Rounded as RepostRepostIcon,
+} from '#/components/icons/Repost'
+import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/Shapes'
+import * as Layout from '#/components/Layout'
+import * as SettingsList from '../components/SettingsList'
+import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
+
+const RQKEY = ['notification-permissions']
+
+type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
+export function NotificationSettingsScreen({}: Props) {
+  const {_} = useLingui()
+  const queryClient = useQueryClient()
+  const {data: settings, isError} = useNotificationSettingsQuery()
+
+  const {data: permissions, refetch} = useQuery({
+    queryKey: RQKEY,
+    queryFn: async () => {
+      if (isWeb) return null
+      return await Notification.getPermissionsAsync()
+    },
+  })
+
+  const appState = useAppState()
+  useEffect(() => {
+    if (appState === 'active') {
+      refetch()
+    }
+  }, [appState, refetch])
+
+  const onRequestPermissions = async () => {
+    if (isWeb) return
+    if (permissions?.canAskAgain) {
+      const response = await Notification.requestPermissionsAsync()
+      queryClient.setQueryData(RQKEY, response)
+    } else {
+      if (isAndroid) {
+        try {
+          await Linking.sendIntent(
+            'android.settings.APP_NOTIFICATION_SETTINGS',
+            [
+              {
+                key: 'android.provider.extra.APP_PACKAGE',
+                value: 'xyz.blueskyweb.app',
+              },
+            ],
+          )
+        } catch {
+          Linking.openSettings()
+        }
+      } else if (isIOS) {
+        Linking.openSettings()
+      }
+    }
+  }
+
+  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>
+      <Layout.Content>
+        <SettingsList.Container>
+          {permissions && !permissions.granted && (
+            <>
+              <SettingsList.PressableItem
+                label={_(msg`Enable push notifications`)}
+                onPress={onRequestPermissions}>
+                <SettingsList.ItemIcon icon={HapticIcon} />
+                <SettingsList.ItemText>
+                  <Trans>Enable push notifications</Trans>
+                </SettingsList.ItemText>
+              </SettingsList.PressableItem>
+              <SettingsList.Divider />
+            </>
+          )}
+          {isError && (
+            <View style={[a.px_lg, a.pb_md]}>
+              <Admonition type="error">
+                <Trans>Failed to load notification settings.</Trans>
+              </Admonition>
+            </View>
+          )}
+          <View style={[a.gap_sm]}>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for reply notifications`)}
+              to={{screen: 'ReplyNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={BubbleIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Replies</Trans>}
+                subtitleText={<SettingPreview preference={settings?.reply} />}
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for mention notifications`)}
+              to={{screen: 'MentionNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={AtIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Mentions</Trans>}
+                subtitleText={<SettingPreview preference={settings?.mention} />}
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for quote notifications`)}
+              to={{screen: 'QuoteNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={CloseQuoteIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Quotes</Trans>}
+                subtitleText={<SettingPreview preference={settings?.quote} />}
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for like notifications`)}
+              to={{screen: 'LikeNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={HeartIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Likes</Trans>}
+                subtitleText={<SettingPreview preference={settings?.like} />}
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for repost notifications`)}
+              to={{screen: 'RepostNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={RepostIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Reposts</Trans>}
+                subtitleText={<SettingPreview preference={settings?.repost} />}
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for new follower notifications`)}
+              to={{screen: 'NewFollowerNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={PersonPlusIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>New followers</Trans>}
+                subtitleText={<SettingPreview preference={settings?.follow} />}
+                showSkeleton={!settings}
+              />
+            </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>}
+                subtitleText={
+                  <SettingPreview preference={settings?.subscribedPost} />
+                }
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem> */}
+            <SettingsList.LinkItem
+              label={_(
+                msg`Settings for notifications for likes on your reposts`,
+              )}
+              to={{screen: 'LikesOnRepostsNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={LikeRepostIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Likes on your reposts</Trans>}
+                subtitleText={
+                  <SettingPreview preference={settings?.likeViaRepost} />
+                }
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(
+                msg`Settings for notifications for reposts of your reposts`,
+              )}
+              to={{screen: 'RepostsOnRepostsNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={RepostRepostIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Reposts of your reposts</Trans>}
+                subtitleText={
+                  <SettingPreview preference={settings?.repostViaRepost} />
+                }
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+            <SettingsList.LinkItem
+              label={_(msg`Settings for notifications for everything else`)}
+              to={{screen: 'MiscellaneousNotificationSettings'}}
+              contentContainerStyle={[a.align_start]}>
+              <SettingsList.ItemIcon icon={ShapesIcon} />
+              <ItemTextWithSubtitle
+                titleText={<Trans>Everything else</Trans>}
+                // technically a bundle of several settings, but since they're set together
+                // and are most likely in sync we'll just show the state of one of them
+                subtitleText={
+                  <SettingPreview preference={settings?.starterpackJoined} />
+                }
+                showSkeleton={!settings}
+              />
+            </SettingsList.LinkItem>
+          </View>
+        </SettingsList.Container>
+      </Layout.Content>
+    </Layout.Screen>
+  )
+}
+
+function SettingPreview({
+  preference,
+}: {
+  preference?:
+    | AppBskyNotificationDefs.Preference
+    | AppBskyNotificationDefs.FilterablePreference
+}) {
+  const {_} = useLingui()
+  if (!preference) {
+    return null
+  } else {
+    if ('filter' in preference) {
+      if (preference.filter === 'all') {
+        if (preference.list && preference.push) {
+          return _(msg`In-app, Push, Everyone`)
+        } else if (preference.list) {
+          return _(msg`In-app, Everyone`)
+        } else if (preference.push) {
+          return _(msg`Push, Everyone`)
+        }
+      } else if (preference.filter === 'follows') {
+        if (preference.list && preference.push) {
+          return _(msg`In-app, Push, People you follow`)
+        } else if (preference.list) {
+          return _(msg`In-app, People you follow`)
+        } else if (preference.push) {
+          return _(msg`Push, People you follow`)
+        }
+      }
+    } else {
+      if (preference.list && preference.push) {
+        return _(msg`In-app, Push`)
+      } else if (preference.list) {
+        return _(msg`In-app`)
+      } else if (preference.push) {
+        return _(msg`Push`)
+      }
+    }
+  }
+
+  return _(msg`Off`)
+}