diff options
author | Samuel Newman <mozzius@protonmail.com> | 2025-06-17 12:37:14 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-17 02:37:14 -0700 |
commit | 21989b558bd074bf84ac08c174d7a411fda1ffb7 (patch) | |
tree | f5f28510cf5a592b83bcfc581a57e992823eb402 /src/screens/Settings/NotificationSettings | |
parent | 7dc6bb57a6666db3e507630c13448487acceadc5 (diff) | |
download | voidsky-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')
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`) +} |