Add new chat notification preferences (#10972)

This commit is contained in:
DS Boyce
2026-06-25 12:01:33 -07:00
committed by GitHub
parent 1ff27bfd3b
commit 3b2e609761
11 changed files with 492 additions and 210 deletions
-16
View File
@@ -946,14 +946,6 @@
"count": 1
}
},
"src/lib/hooks/useNotificationHandler.ts": {
"@typescript-eslint/no-floating-promises": {
"count": 16
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/lib/hooks/useOTAUpdates.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 2
@@ -2183,14 +2175,6 @@
"count": 2
}
},
"src/view/com/modals/Modal.tsx": {
"@typescript-eslint/no-misused-promises": {
"count": 1
},
"@typescript-eslint/require-await": {
"count": 1
}
},
"src/view/com/notifications/NotificationFeed.tsx": {
"@typescript-eslint/no-explicit-any": {
"count": 2
+1 -1
View File
@@ -94,7 +94,7 @@
"update-actions": "pnpm dlx actions-up --min-age 7"
},
"dependencies": {
"@atproto/api": "0.20.15",
"@atproto/api": "0.20.20",
"@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
+40 -31
View File
@@ -241,8 +241,8 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.15
version: 0.20.15
specifier: 0.20.20
version: 0.20.20
'@atproto/syntax':
specifier: 0.6.1
version: 0.6.1
@@ -876,32 +876,36 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.15':
resolution: {integrity: sha512-b9TuVNY9iWIaRXAeKegNCqRsK9tSpB68DE/j/ytVTxEMK+/m43B0DycJND9tnhRiNIY+i7MhwLqNHPak9YaDJg==}
'@atproto/api@0.20.20':
resolution: {integrity: sha512-+yqxLCvMu36xRxwc8/x0mhptZBC+/cbfPwdzeOtlBUeVi4ZB/5RWg2L4lWWdhBSiQBZw0AgaPg/2zsx4lDFgjg==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.0':
resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
'@atproto/common-web@0.5.1':
resolution: {integrity: sha512-nru02gLyzjMQn4ZFgms9ry3kIAKwMaCAvjeFhB9mU6h1ABiSho7aRDbGvnU50CC88TROXuZlgRiEkBjnuiHEtA==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.1':
resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
'@atproto/lex-data@0.1.2':
resolution: {integrity: sha512-NZ4iZvNaqTM6pz+9VRDyEjtozrrf2EDHySNi3Xa8PCzS8gRMCvsnnsvM7r264v4eSqNIDhBB8fr9hfWCHMspXg==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.0':
resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
'@atproto/lex-json@0.1.1':
resolution: {integrity: sha512-FC/NsKHm8TDzWikvf/268T3mMAh6+f31yfCApWC3SvrhWc9c06cSYPp7qzpXcdV0OdB+I5dqHScuTB5OC7HrXg==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.1':
resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
'@atproto/lexicon@0.7.2':
resolution: {integrity: sha512-LVZcTr5+9Qh0okZnNmfRiIDPfbL/6rRxf4goiQxPxwDzaeUDhn4M209i1drmiitbCO1qRLJA2hHF54yNA75Cig==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.1':
resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.0':
resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
'@atproto/syntax@0.6.2':
resolution: {integrity: sha512-h3njTNFl/jv5kTbDqfamQGOJfGbulqLDuAiLbasKwVdBhFyQanpRLa6vE2WqTwv1XEFWLYNMH0KCVsYwT2+aww==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.1':
resolution: {integrity: sha512-sKopRG3an6LN6NfHnRNIEX1fBx3CRtxbNFWQxyse2f86wMcTuXM+/+olN4lVXgFQgv4AWvKTxRXWyuIF2G4YxQ==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -9472,40 +9476,40 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.15':
'@atproto/api@0.20.20':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1
'@atproto/syntax': 0.6.1
'@atproto/xrpc': 0.8.0
'@atproto/common-web': 0.5.1
'@atproto/lexicon': 0.7.2
'@atproto/syntax': 0.6.2
'@atproto/xrpc': 0.8.1
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.0':
'@atproto/common-web@0.5.1':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-json': 0.1.0
'@atproto/syntax': 0.6.1
'@atproto/lex-data': 0.1.2
'@atproto/lex-json': 0.1.1
'@atproto/syntax': 0.6.2
zod: 3.25.76
'@atproto/lex-data@0.1.1':
'@atproto/lex-data@0.1.2':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.0':
'@atproto/lex-json@0.1.1':
dependencies:
'@atproto/lex-data': 0.1.1
'@atproto/lex-data': 0.1.2
tslib: 2.8.1
'@atproto/lexicon@0.7.1':
'@atproto/lexicon@0.7.2':
dependencies:
'@atproto/common-web': 0.5.0
'@atproto/syntax': 0.6.1
'@atproto/common-web': 0.5.1
'@atproto/syntax': 0.6.2
multiformats: 13.4.2
zod: 3.25.76
@@ -9514,9 +9518,14 @@ snapshots:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.0':
'@atproto/syntax@0.6.2':
dependencies:
'@atproto/lexicon': 0.7.1
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.1':
dependencies:
'@atproto/lexicon': 0.7.2
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -1,8 +1,12 @@
import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {
isChatPreferenceName,
type NotificationSettingsPreferenceName,
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {PreferenceControls} from '#/screens/Settings/NotificationSettings/components/PreferenceControls'
import {atoms as a, useTheme, web} from '#/alf'
@@ -15,8 +19,8 @@ import {IS_NATIVE} from '#/env'
type NotificationSettingsDialogProps = {
control: Dialog.DialogControlProps
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
name: NotificationSettingsPreferenceName
syncOthers?: NotificationSettingsPreferenceName[]
icon: React.ComponentType<SVGIconProps>
titleText: React.ReactNode
subtitleText: React.ReactNode
@@ -55,7 +59,11 @@ function NotificationSettingsDialogInner({
}: Omit<NotificationSettingsDialogProps, 'icon'>) {
const t = useTheme()
const {t: l} = useLingui()
const {data: preferences, isError} = useNotificationSettingsQuery()
const isChat = isChatPreferenceName(name)
const appQuery = useNotificationSettingsQuery({enabled: !isChat})
const chatQuery = useChatNotificationSettingsQuery({enabled: isChat})
const isError = isChat ? chatQuery.isError : appQuery.isError
const preference = isChat ? chatQuery.data?.[name] : appQuery.data?.[name]
return (
<>
@@ -81,7 +89,7 @@ function NotificationSettingsDialogInner({
<PreferenceControls
name={name}
syncOthers={syncOthers}
preference={preferences?.[name]}
preference={preference}
allowDisableInApp={allowDisableInApp}
/>
)}
@@ -89,7 +97,7 @@ function NotificationSettingsDialogInner({
<Dialog.Close />
{IS_NATIVE && (
<Button
color="secondary"
color="primary"
size="large"
label={l`Close dialog`}
onPress={() => control.close()}
+38 -40
View File
@@ -1,8 +1,7 @@
import {useEffect} from 'react'
import * as Notifications from 'expo-notifications'
import {AtUri} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {CommonActions, useNavigation} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
@@ -108,6 +107,7 @@ let lastHandledNotificationDateDedupe = 0
export function useNotificationsHandler() {
const ax = useAnalytics()
// eslint-disable-next-line react-compiler/react-compiler
const logger = ax.logger.useChild(ax.logger.Context.Notifications)
const queryClient = useQueryClient()
const {currentAccount, accounts} = useSession()
@@ -116,7 +116,7 @@ export function useNotificationsHandler() {
const {currentConvoId} = useCurrentConvoId()
const {setShowLoggedOut} = useLoggedOutViewControls()
const closeAllActiveElements = useCloseAllActiveElements()
const {_} = useLingui()
const {t: l} = useLingui()
// On Android, we cannot control which sound is used for a notification on Android
// 28 or higher. Instead, we have to configure a notification channel ahead of time
@@ -129,14 +129,12 @@ export function useNotificationsHandler() {
// NOTE: I don't think that it will retroactively move them into the group
// if the channels already exist. no big deal imo -sfn
const CHAT_GROUP = 'chat'
Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: _(msg`Chat`),
description: _(
msg`You can choose whether chat notifications have sound in the chat settings within the app`,
),
void Notifications.setNotificationChannelGroupAsync(CHAT_GROUP, {
name: l`Chat`,
description: l`You can choose whether chat notifications have sound in the chat settings within the app`,
})
Notifications.setNotificationChannelAsync('chat-messages', {
name: _(msg`Chat messages - sound`),
void Notifications.setNotificationChannelAsync('chat-messages', {
name: l`Chat messages - sound`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: 'dm.mp3',
@@ -144,8 +142,8 @@ export function useNotificationsHandler() {
vibrationPattern: [250],
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: _(msg`Chat messages - silent`),
void Notifications.setNotificationChannelAsync('chat-messages-muted', {
name: l`Chat messages - silent`,
groupId: CHAT_GROUP,
importance: Notifications.AndroidImportance.MAX,
sound: null,
@@ -154,70 +152,70 @@ export function useNotificationsHandler() {
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
})
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'like' satisfies NotificationReason,
{
name: _(msg`Likes`),
name: l`Likes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'repost' satisfies NotificationReason,
{
name: _(msg`Reposts`),
name: l`Reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'reply' satisfies NotificationReason,
{
name: _(msg`Replies`),
name: l`Replies`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'mention' satisfies NotificationReason,
{
name: _(msg`Mentions`),
name: l`Mentions`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'quote' satisfies NotificationReason,
{
name: _(msg`Quotes`),
name: l`Quotes`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'follow' satisfies NotificationReason,
{
name: _(msg`New followers`),
name: l`New followers`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'like-via-repost' satisfies NotificationReason,
{
name: _(msg`Likes of your reposts`),
name: l`Likes of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'repost-via-repost' satisfies NotificationReason,
{
name: _(msg`Reposts of your reposts`),
name: l`Reposts of your reposts`,
importance: Notifications.AndroidImportance.HIGH,
},
)
Notifications.setNotificationChannelAsync(
void Notifications.setNotificationChannelAsync(
'subscribed-post' satisfies NotificationReason,
{
name: _(msg`Activity from others`),
name: l`Activity from others`,
importance: Notifications.AndroidImportance.HIGH,
},
)
}, [_])
}, [l])
useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
@@ -237,7 +235,7 @@ export function useNotificationsHandler() {
const account = accounts.find(a => a.did === payload.recipientDid)
if (account) {
onPressSwitchAccount(account, 'Notification')
void onPressSwitchAccount(account, 'Notification')
} else {
setShowLoggedOut(true)
}
@@ -305,10 +303,10 @@ export function useNotificationsHandler() {
}
Notifications.setNotificationHandler({
handleNotification: async e => {
handleNotification: e => {
const payload = getNotificationPayload(e)
if (!payload) return DEFAULT_HANDLER_OPTIONS
if (!payload) return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
logger.debug('useNotificationsHandler: incoming', {e, payload})
@@ -323,17 +321,17 @@ export function useNotificationsHandler() {
payload.reason === 'chat-removed-from-group' ||
payload.reason === 'chat-join-request-rejected' ||
payload.convoId !== currentConvoId
return {
return Promise.resolve({
shouldShowList: shouldAlert,
shouldShowBanner: shouldAlert,
shouldPlaySound: false,
shouldSetBadge: false,
} satisfies Notifications.NotificationBehavior
} satisfies Notifications.NotificationBehavior)
}
// Any notification other than a chat message should invalidate the unread page
invalidateCachedUnreadPage()
return DEFAULT_HANDLER_OPTIONS
return Promise.resolve(DEFAULT_HANDLER_OPTIONS)
},
})
@@ -363,14 +361,14 @@ export function useNotificationsHandler() {
})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if (
payload.reason === 'mention' ||
payload.reason === 'quote' ||
payload.reason === 'reply'
) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
void truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
logger.debug('Notifications: handleNotification', {
@@ -379,7 +377,7 @@ export function useNotificationsHandler() {
})
handleNotification(payload)
Notifications.dismissAllNotificationsAsync()
void Notifications.dismissAllNotificationsAsync()
// Also clear the native `lastResponse` cache. Otherwise a subsequent
// `getLastNotificationResponse()` (e.g. on an account-switch remount,
// which re-runs `handlePushNotificationEntry`) would replay this
+103 -8
View File
@@ -1,13 +1,19 @@
import {useCallback} from 'react'
import {View} from 'react-native'
import {Pressable, View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {useUpdateActorDeclaration} from '#/state/queries/messages/actor-declaration'
import {
type NotificationSettingsPreference,
useChatNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {ExportCarDialog} from '#/screens/Settings/components/ExportCarDialog'
import {ChatNotificationDialogs} from '#/screens/Settings/NotificationSettings/components/ChatNotificationDialogs'
import {SettingPreview} from '#/screens/Settings/NotificationSettings/components/SettingPreview'
import {atoms as a, useTheme} from '#/alf'
import {AgeRestrictedScreen} from '#/components/ageAssurance/AgeRestrictedScreen'
import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy'
@@ -18,7 +24,10 @@ import * as Toggle from '#/components/forms/Toggle'
import {Bell_Stroke2_Corner0_Rounded as BellIcon} from '#/components/icons/Bell'
import {Car_Stroke2_Corner2_Rounded as CarIcon} from '#/components/icons/Car'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import * as Layout from '#/components/Layout'
import * as Skele from '#/components/Skeleton'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
@@ -52,8 +61,12 @@ export function MessagesSettingsScreenInner({}: Props) {
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
})
const {data: chatNotificationSettings, isError: chatSettingsError} =
useChatNotificationSettingsQuery()
const {preferences, setPref} = useBackgroundNotificationPreferences()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const exportCarControl = Dialog.useDialogControl()
const isGroupChatEnabled = !ax.features.enabled(ax.features.GroupChatsDisable)
@@ -237,6 +250,54 @@ export function MessagesSettingsScreenInner({}: Props) {
<Divider style={{marginVertical: 10}} />
</>
) : null}
<View style={[a.px_xl, a.gap_lg]}>
<Text style={[a.pb_xs, a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>Notifications</Trans>
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new messages`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatDialogControl.open()
}}>
<MessageIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New messages</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chat}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Settings for notifications for new message requests`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_start, a.justify_between, a.gap_sm]}
onPress={() => {
chatRequestDialogControl.open()
}}>
<EnvelopeIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
<View style={[a.flex_1, a.flex_grow]}>
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
<Trans>New message requests</Trans>
</Text>
<NotificationPreferenceSubtitle
preference={chatNotificationSettings?.chatRequest}
isLoading={!chatNotificationSettings}
isError={chatSettingsError}
/>
</View>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
{IS_NATIVE && (
<>
<View style={[a.px_xl]}>
@@ -263,12 +324,12 @@ export function MessagesSettingsScreenInner({}: Props) {
</>
)}
<View style={[a.px_xl]}>
<Toggle.Item
label={l`Export my chat data`}
name="playSoundChat"
value={preferences.playSoundChat}
style={[a.flex_row, a.align_center, a.justify_between]}
onChange={() => {
<Pressable
accessibilityRole="button"
accessibilityLabel={l`Export my chat data`}
accessibilityHint={undefined}
style={[a.flex_row, a.align_center, a.justify_between, a.gap_sm]}
onPress={() => {
exportCarControl.open()
}}>
<CarIcon style={[a.mr_2xs, t.atoms.text]} size="lg" />
@@ -277,12 +338,46 @@ export function MessagesSettingsScreenInner({}: Props) {
<Trans>Export my chat data</Trans>
</Text>
<ChevronRightIcon style={[a.ml_2xs, t.atoms.text]} size="lg" />
</Toggle.Item>
</Pressable>
</View>
<Divider style={{marginVertical: 10}} />
</View>
</Layout.Content>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<ExportCarDialog control={exportCarControl} />
</Layout.Screen>
)
}
function NotificationPreferenceSubtitle({
preference,
isLoading,
isError,
}: {
preference?: NotificationSettingsPreference
isLoading: boolean
isError: boolean
}) {
const t = useTheme()
if (isError) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<Trans>Failed to load notification settings.</Trans>
</Text>
)
}
if (isLoading) {
return <Skele.Text style={[a.text_sm, {width: 120}]} />
}
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium, a.leading_snug]}>
<SettingPreview preference={preference} />
</Text>
)
}
@@ -0,0 +1,41 @@
import {Trans} from '@lingui/react/macro'
import type * as Dialog from '#/components/Dialog'
import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSettingsDialog'
import {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
import {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
export function ChatNotificationDialogs({
chatControl,
chatRequestControl,
}: {
chatControl: Dialog.DialogControlProps
chatRequestControl: Dialog.DialogControlProps
}) {
return (
<>
<NotificationSettingsDialog
control={chatControl}
name="chat"
icon={MessageIcon}
titleText={<Trans>New messages</Trans>}
subtitleText={
<Trans>Get notifications when people send you messages.</Trans>
}
allowDisableInApp={false}
/>
<NotificationSettingsDialog
control={chatRequestControl}
name="chatRequest"
icon={EnvelopeIcon}
titleText={<Trans>New message requests</Trans>}
subtitleText={
<Trans>
Get notifications when people send you message requests.
</Trans>
}
allowDisableInApp={false}
/>
</>
)
}
@@ -1,9 +1,12 @@
import {useMemo} from 'react'
import {View} from 'react-native'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useNotificationSettingsUpdateMutation} from '#/state/queries/notifications/settings'
import {
type NotificationSettingsPreference,
type NotificationSettingsPreferenceName,
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'
@@ -17,16 +20,13 @@ export function PreferenceControls({
preference,
allowDisableInApp = true,
}: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
name: NotificationSettingsPreferenceName
/**
* 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
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
syncOthers?: NotificationSettingsPreferenceName[]
preference?: NotificationSettingsPreference
allowDisableInApp?: boolean
}) {
if (!preference)
@@ -52,12 +52,9 @@ export function Inner({
preference,
allowDisableInApp,
}: {
name: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>
syncOthers?: Exclude<keyof AppBskyNotificationDefs.Preferences, '$type'>[]
preference:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| AppBskyNotificationDefs.ChatPreference
name: NotificationSettingsPreferenceName
syncOthers?: NotificationSettingsPreferenceName[]
preference: NotificationSettingsPreference
allowDisableInApp: boolean
}) {
const t = useTheme()
@@ -95,7 +92,7 @@ export function Inner({
}
const onChangeFilter = ([change]: string[]) => {
if (change !== 'all' && change !== 'follows' && change !== 'accepted')
if (change !== 'all' && change !== 'follows')
throw new Error('Invalid filter')
const newPreference = {
@@ -135,7 +132,7 @@ export function Inner({
</Toggle.LabelText>
<Toggle.Platform />
</Toggle.Item>
{allowDisableInApp && (
{allowDisableInApp && 'list' in preference && (
<Toggle.Item
label={l`Receive in-app notifications`}
name="list"
@@ -176,31 +173,17 @@ export function Inner({
/>
)}
</Toggle.Item>
{name === 'chat' ? (
<Toggle.Item
highlightRow
label={l`Accepted conversations`}
name="accepted">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`Accepted conversations`}
selected={selected}
/>
)}
</Toggle.Item>
) : (
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
)}
<Toggle.Item
highlightRow
label={l`People I follow`}
name="follows">
{({selected}) => (
<Toggle.RadioWithLabel
label={l`People I follow`}
selected={selected}
/>
)}
</Toggle.Item>
</View>
</Toggle.Group>
</>
@@ -0,0 +1,35 @@
import {useLingui} from '@lingui/react/macro'
import {type NotificationSettingsPreference} from '#/state/queries/notifications/settings'
export function SettingPreview({
preference,
}: {
preference?: NotificationSettingsPreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
}
if ('include' in preference) {
const list = 'list' in preference && preference.list
if (preference.include === 'all') {
if (list && preference.push) return l`In-app, push, everyone`
if (list) return l`In-app, everyone`
if (preference.push) return l`Push, everyone`
} else if (preference.include === 'follows') {
if (list && preference.push) return l`In-app, push, people you follow`
if (list) return l`In-app, people you follow`
if (preference.push) return l`Push, people you follow`
}
} else {
if (preference.list && preference.push) return l`In-app, push`
if (preference.list) return l`In-app`
if (preference.push) return l`Push`
}
return l`Off`
}
@@ -1,7 +1,6 @@
import {useEffect} from 'react'
import {Linking, View} from 'react-native'
import * as Notification from 'expo-notifications'
import {type AppBskyNotificationDefs} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import {useQuery, useQueryClient} from '@tanstack/react-query'
@@ -10,7 +9,10 @@ import {
type AllNavigatorParams,
type NativeStackScreenProps,
} from '#/lib/routes/types'
import {useNotificationSettingsQuery} from '#/state/queries/notifications/settings'
import {
useChatNotificationSettingsQuery,
useNotificationSettingsQuery,
} from '#/state/queries/notifications/settings'
import {atoms as a} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Dialog from '#/components/Dialog'
@@ -18,11 +20,13 @@ import {NotificationSettingsDialog} from '#/components/dialogs/NotificationSetti
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 {Envelope_Stroke2_Corner2_Rounded as EnvelopeIcon} from '#/components/icons/Envelope'
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 {Message_Stroke2_Corner0_Rounded as MessageIcon} from '#/components/icons/Message'
import {PersonPlus_Stroke2_Corner2_Rounded as PersonPlusIcon} from '#/components/icons/Person'
import {CloseQuote_Stroke2_Corner0_Rounded as CloseQuoteIcon} from '#/components/icons/Quote'
import {
@@ -33,7 +37,9 @@ import {Shapes_Stroke2_Corner0_Rounded as ShapesIcon} from '#/components/icons/S
import * as Layout from '#/components/Layout'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
import * as SettingsList from '../components/SettingsList'
import {ChatNotificationDialogs} from './components/ChatNotificationDialogs'
import {ItemTextWithSubtitle} from './components/ItemTextWithSubtitle'
import {SettingPreview} from './components/SettingPreview'
const RQKEY = ['notification-permissions']
@@ -42,6 +48,8 @@ export function NotificationSettingsScreen({}: Props) {
const {t: l} = useLingui()
const queryClient = useQueryClient()
const {data: settings, isError} = useNotificationSettingsQuery()
const {data: chatSettings, isError: chatError} =
useChatNotificationSettingsQuery()
const likeDialogControl = Dialog.useDialogControl()
const followDialogControl = Dialog.useDialogControl()
@@ -52,6 +60,8 @@ export function NotificationSettingsScreen({}: Props) {
const activityDialogControl = Dialog.useDialogControl()
const likeRepostDialogControl = Dialog.useDialogControl()
const repostRepostDialogControl = Dialog.useDialogControl()
const chatDialogControl = Dialog.useDialogControl()
const chatRequestDialogControl = Dialog.useDialogControl()
const miscDialogControl = Dialog.useDialogControl()
const {data: permissions, refetch} = useQuery({
@@ -234,6 +244,40 @@ export function NotificationSettingsScreen({}: Props) {
showSkeleton={!settings}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new messages`}
onPress={chatDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={MessageIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New messages</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chat} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for new message requests`}
onPress={chatRequestDialogControl.open}
contentContainerStyle={[a.align_start]}>
<SettingsList.ItemIcon icon={EnvelopeIcon} />
<ItemTextWithSubtitle
titleText={<Trans>New message requests</Trans>}
subtitleText={
chatError ? (
<Trans>Failed to load notification settings.</Trans>
) : (
<SettingPreview preference={chatSettings?.chatRequest} />
)
}
showSkeleton={!chatSettings && !chatError}
/>
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={l`Settings for notifications for everything else`}
onPress={miscDialogControl.open}
@@ -333,6 +377,10 @@ export function NotificationSettingsScreen({}: Props) {
<Trans>Get notifications when people repost your reposts.</Trans>
}
/>
<ChatNotificationDialogs
chatControl={chatDialogControl}
chatRequestControl={chatRequestDialogControl}
/>
<NotificationSettingsDialog
control={miscDialogControl}
name="starterpackJoined"
@@ -350,46 +398,3 @@ export function NotificationSettingsScreen({}: Props) {
</Layout.Screen>
)
}
function SettingPreview({
preference,
}: {
preference?:
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
}) {
const {t: l} = useLingui()
if (!preference) {
return null
} else {
if ('include' in preference) {
if (preference.include === 'all') {
if (preference.list && preference.push) {
return l`In-app, push, everyone`
} else if (preference.list) {
return l`In-app, everyone`
} else if (preference.push) {
return l`Push, everyone`
}
} else if (preference.include === 'follows') {
if (preference.list && preference.push) {
return l`In-app, push, people you follow`
} else if (preference.list) {
return l`In-app, people you follow`
} else if (preference.push) {
return l`Push, people you follow`
}
}
} else {
if (preference.list && preference.push) {
return l`In-app, push`
} else if (preference.list) {
return l`In-app`
} else if (preference.push) {
return l`Push`
}
}
}
return l`Off`
}
+145 -21
View File
@@ -1,4 +1,7 @@
import {type AppBskyNotificationDefs} from '@atproto/api'
import {
type AppBskyNotificationDefs,
type ChatBskyNotificationDefs,
} from '@atproto/api'
import {t} from '@lingui/core/macro'
import {
type QueryClient,
@@ -7,12 +10,59 @@ import {
useQueryClient,
} from '@tanstack/react-query'
import {DM_SERVICE_HEADERS} from '#/lib/constants'
import {logger} from '#/logger'
import {useAgent} from '#/state/session'
import * as Toast from '#/components/Toast'
const RQKEY_ROOT = 'notification-settings'
const RQKEY = [RQKEY_ROOT]
const RQKEY_APP = [RQKEY_ROOT, 'app']
const RQKEY_CHAT = [RQKEY_ROOT, 'chat']
// App notification preferences live on the appview. Chat preferences live on a
// separate chat service proxy that can be up or down independently, so they are
// fetched and cached separately. This combined type names every preference for
// the generic settings dialog, but it is never the shape of a query response.
export type NotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
> &
Partial<Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'>>
export type AppNotificationSettingsPreferences = Omit<
AppBskyNotificationDefs.Preferences,
'chat'
>
export type ChatNotificationSettingsPreferences = Pick<
ChatBskyNotificationDefs.Preferences,
'chat' | 'chatRequest'
>
export type NotificationSettingsPreferenceName = Exclude<
keyof NotificationSettingsPreferences,
'$type'
>
export type NotificationSettingsPreference =
| AppBskyNotificationDefs.Preference
| AppBskyNotificationDefs.FilterablePreference
| ChatBskyNotificationDefs.ChatPreference
export function isChatPreferenceName(
name: NotificationSettingsPreferenceName,
): name is 'chat' | 'chatRequest' {
return name === 'chat' || name === 'chatRequest'
}
type NotificationSettingsUpdate = Partial<NotificationSettingsPreferences>
type AppNotificationSettingsUpdate = Partial<
Omit<AppBskyNotificationDefs.Preferences, '$type' | 'chat'>
>
type ChatNotificationSettingsUpdate =
Partial<ChatNotificationSettingsPreferences>
export function useNotificationSettingsQuery({
enabled,
@@ -20,10 +70,27 @@ export function useNotificationSettingsQuery({
const agent = useAgent()
return useQuery({
queryKey: RQKEY,
queryFn: async () => {
const response = await agent.app.bsky.notification.getPreferences()
return response.data.preferences
queryKey: RQKEY_APP,
queryFn: async (): Promise<AppNotificationSettingsPreferences> => {
const res = await agent.app.bsky.notification.getPreferences()
return appPreferencesWithoutChat(res.data.preferences)
},
enabled,
})
}
export function useChatNotificationSettingsQuery({
enabled,
}: {enabled?: boolean} = {}) {
const agent = useAgent()
return useQuery({
queryKey: RQKEY_CHAT,
queryFn: async (): Promise<ChatNotificationSettingsPreferences> => {
const res = await agent.chat.bsky.notification.getPreferences(undefined, {
headers: DM_SERVICE_HEADERS,
})
return chatPreferencesForSettings(res.data.preferences)
},
enabled,
})
@@ -33,19 +100,27 @@ export function useNotificationSettingsUpdateMutation() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (
update: Partial<AppBskyNotificationDefs.Preferences>,
) => {
const response =
await agent.app.bsky.notification.putPreferencesV2(update)
return response.data.preferences
mutationFn: async (update: NotificationSettingsUpdate) => {
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
await Promise.all([
hasUpdates(appUpdate)
? agent.app.bsky.notification.putPreferencesV2(appUpdate)
: undefined,
hasUpdates(chatUpdate)
? agent.chat.bsky.notification.putPreferences(chatUpdate, {
headers: DM_SERVICE_HEADERS,
encoding: 'application/json',
})
: undefined,
])
},
onMutate: update => {
optimisticUpdateNotificationSettings(queryClient, update)
},
onError: e => {
logger.error('Could not update notification settings', {message: e})
void queryClient.invalidateQueries({queryKey: RQKEY})
void queryClient.invalidateQueries({queryKey: RQKEY_APP})
void queryClient.invalidateQueries({queryKey: RQKEY_CHAT})
Toast.show(t`Could not update notification settings`, {
type: 'error',
})
@@ -55,13 +130,62 @@ export function useNotificationSettingsUpdateMutation() {
function optimisticUpdateNotificationSettings(
queryClient: QueryClient,
update: Partial<AppBskyNotificationDefs.Preferences>,
update: NotificationSettingsUpdate,
) {
queryClient.setQueryData(
RQKEY,
(old?: AppBskyNotificationDefs.Preferences) => {
if (!old) return old
return {...old, ...update}
},
)
const {appUpdate, chatUpdate} = splitNotificationSettingsUpdate(update)
if (hasUpdates(appUpdate)) {
queryClient.setQueryData(
RQKEY_APP,
(old?: AppNotificationSettingsPreferences) => {
if (!old) return old
return {...old, ...appUpdate}
},
)
}
if (hasUpdates(chatUpdate)) {
queryClient.setQueryData(
RQKEY_CHAT,
(old?: ChatNotificationSettingsPreferences) => {
if (!old) return old
return {...old, ...chatUpdate}
},
)
}
}
function appPreferencesWithoutChat(
preferences: AppBskyNotificationDefs.Preferences,
): Omit<AppBskyNotificationDefs.Preferences, 'chat'> {
const {chat: _ignoredChat, ...appPreferences} = preferences
return appPreferences
}
function chatPreferencesForSettings(
preferences: ChatBskyNotificationDefs.Preferences,
): Pick<ChatBskyNotificationDefs.Preferences, 'chat' | 'chatRequest'> {
return {
chat: preferences.chat,
chatRequest: preferences.chatRequest,
}
}
function splitNotificationSettingsUpdate(update: NotificationSettingsUpdate): {
appUpdate: AppNotificationSettingsUpdate
chatUpdate: ChatNotificationSettingsUpdate
} {
const {chat, chatRequest, $type: _type, ...appUpdate} = update
return {
appUpdate: appUpdate,
chatUpdate: {
...(chat !== undefined ? {chat} : {}),
...(chatRequest !== undefined ? {chatRequest} : {}),
},
}
}
function hasUpdates(update: object) {
return Object.keys(update).length > 0
}