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>
This commit is contained in:
Samuel Newman
2025-07-02 00:36:04 +03:00
committed by GitHub
parent 8f9a8ddce0
commit bc072570d2
53 changed files with 1810 additions and 288 deletions
@@ -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>
)
}
@@ -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={[
@@ -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`,