Priority notifications (#4798)

* new settings screen

* bring back the spinner

* add experimental language

* fix typo, change leading

* integrate priority notifications API

* update package

* use refetch instead of invalidateQueries

* fix read-after-write issue by polling for update

* add spinner for initial load

* rm onmutate, it's overcomplicated

* set error state eagerly

* Change language in description

Co-authored-by: Hailey <me@haileyok.com>

* prettier

* add `Toggle.Platform`

* extract out mutation hook + error state

* rm useless cache mutation

* disambiguate isError and isPending

* rm unused isError

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>
zio/stable
Samuel Newman 2024-07-24 20:09:20 +01:00 committed by GitHub
parent 9bd8393685
commit cfb8a3160e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 305 additions and 84 deletions

View File

@ -197,6 +197,7 @@ func serve(cctx *cli.Context) error {
e.GET("/search", server.WebGeneric) e.GET("/search", server.WebGeneric)
e.GET("/feeds", server.WebGeneric) e.GET("/feeds", server.WebGeneric)
e.GET("/notifications", server.WebGeneric) e.GET("/notifications", server.WebGeneric)
e.GET("/notifications/settings", server.WebGeneric)
e.GET("/lists", server.WebGeneric) e.GET("/lists", server.WebGeneric)
e.GET("/moderation", server.WebGeneric) e.GET("/moderation", server.WebGeneric)
e.GET("/moderation/modlists", server.WebGeneric) e.GET("/moderation/modlists", server.WebGeneric)

View File

@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.12.23", "@atproto/api": "0.12.25",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -76,6 +76,7 @@ import {LogScreen} from './view/screens/Log'
import {ModerationModlistsScreen} from './view/screens/ModerationModlists' import {ModerationModlistsScreen} from './view/screens/ModerationModlists'
import {NotFoundScreen} from './view/screens/NotFound' import {NotFoundScreen} from './view/screens/NotFound'
import {NotificationsScreen} from './view/screens/Notifications' import {NotificationsScreen} from './view/screens/Notifications'
import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings'
import {PostLikedByScreen} from './view/screens/PostLikedBy' import {PostLikedByScreen} from './view/screens/PostLikedBy'
import {PostRepostedByScreen} from './view/screens/PostRepostedBy' import {PostRepostedByScreen} from './view/screens/PostRepostedBy'
import {PostThreadScreen} from './view/screens/PostThread' import {PostThreadScreen} from './view/screens/PostThread'
@ -324,6 +325,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
getComponent={() => MessagesSettingsScreen} getComponent={() => MessagesSettingsScreen}
options={{title: title(msg`Chat settings`), requireAuth: true}} options={{title: title(msg`Chat settings`), requireAuth: true}}
/> />
<Stack.Screen
name="NotificationsSettings"
getComponent={() => NotificationsSettingsScreen}
options={{title: title(msg`Notification settings`), requireAuth: true}}
/>
<Stack.Screen <Stack.Screen
name="Feeds" name="Feeds"
getComponent={() => FeedsScreen} getComponent={() => FeedsScreen}

View File

@ -189,7 +189,7 @@ let ListMaybePlaceholder = ({
return ( return (
<Error <Error
title={errorTitle ?? _(msg`Oops!`)} title={errorTitle ?? _(msg`Oops!`)}
message={errorMessage ?? _(`Something went wrong!`)} message={errorMessage ?? _(msg`Something went wrong!`)}
onRetry={onRetry} onRetry={onRetry}
onGoBack={onGoBack} onGoBack={onGoBack}
sideBorders={sideBorders} sideBorders={sideBorders}

View File

@ -2,6 +2,7 @@ import React from 'react'
import {Pressable, View, ViewStyle} from 'react-native' import {Pressable, View, ViewStyle} from 'react-native'
import Animated, {LinearTransition} from 'react-native-reanimated' import Animated, {LinearTransition} from 'react-native-reanimated'
import {isNative} from '#/platform/detection'
import {HITSLOP_10} from 'lib/constants' import {HITSLOP_10} from 'lib/constants'
import { import {
atoms as a, atoms as a,
@ -459,3 +460,5 @@ export function Radio() {
</View> </View>
) )
} }
export const Platform = isNative ? Switch : Checkbox

View File

@ -1,5 +0,0 @@
import {createSinglePathSVG} from './TEMPLATE'
export const SettingsGear2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M11.1 2a1 1 0 0 0-.832.445L8.851 4.57 6.6 4.05a1 1 0 0 0-.932.268l-1.35 1.35a1 1 0 0 0-.267.932l.52 2.251-2.126 1.417A1 1 0 0 0 2 11.1v1.8a1 1 0 0 0 .445.832l2.125 1.417-.52 2.251a1 1 0 0 0 .268.932l1.35 1.35a1 1 0 0 0 .932.267l2.251-.52 1.417 2.126A1 1 0 0 0 11.1 22h1.8a1 1 0 0 0 .832-.445l1.417-2.125 2.251.52a1 1 0 0 0 .932-.268l1.35-1.35a1 1 0 0 0 .267-.932l-.52-2.251 2.126-1.417A1 1 0 0 0 22 12.9v-1.8a1 1 0 0 0-.445-.832L19.43 8.851l.52-2.251a1 1 0 0 0-.268-.932l-1.35-1.35a1 1 0 0 0-.932-.267l-2.251.52-1.417-2.126A1 1 0 0 0 12.9 2h-1.8Zm-.968 4.255L11.635 4h.73l1.503 2.255a1 1 0 0 0 1.057.42l2.385-.551.566.566-.55 2.385a1 1 0 0 0 .42 1.057L20 11.635v.73l-2.255 1.503a1 1 0 0 0-.42 1.057l.551 2.385-.566.566-2.385-.55a1 1 0 0 0-1.057.42L12.365 20h-.73l-1.503-2.255a1 1 0 0 0-1.057-.42l-2.385.551-.566-.566.55-2.385a1 1 0 0 0-.42-1.057L4 12.365v-.73l2.255-1.503a1 1 0 0 0 .42-1.057L6.123 6.69l.566-.566 2.385.55a1 1 0 0 0 1.057-.42ZM8 12a4 4 0 1 1 8 0 4 4 0 0 1-8 0Zm4-2a2 2 0 1 0 0 4 2 2 0 0 0 0-4Z',
})

View File

@ -1,10 +1,10 @@
import {timeout} from './timeout' import {timeout} from './timeout'
export async function until( export async function until<T>(
retries: number, retries: number,
delay: number, delay: number,
cond: (v: any, err: any) => boolean, cond: (v: T, err: any) => boolean,
fn: () => Promise<any>, fn: () => Promise<T>,
): Promise<boolean> { ): Promise<boolean> {
while (retries > 0) { while (retries > 0) {
try { try {
@ -13,7 +13,9 @@ export async function until(
return true return true
} }
} catch (e: any) { } catch (e: any) {
if (cond(undefined, e)) { // TODO: change the type signature of cond to accept undefined
// however this breaks every existing usage of until -sfn
if (cond(undefined as unknown as T, e)) {
return true return true
} }
} }

View File

@ -42,14 +42,13 @@ export type CommonNavigatorParams = {
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
MessagesConversation: {conversation: string; embed?: string} MessagesConversation: {conversation: string; embed?: string}
MessagesSettings: undefined MessagesSettings: undefined
NotificationsSettings: undefined
Feeds: undefined Feeds: undefined
Start: {name: string; rkey: string} Start: {name: string; rkey: string}
StarterPack: {name: string; rkey: string; new?: boolean} StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string} StarterPackShort: {code: string}
StarterPackWizard: undefined StarterPackWizard: undefined
StarterPackEdit: { StarterPackEdit: {rkey?: string}
rkey?: string
}
} }
export type BottomTabNavigatorParams = CommonNavigatorParams & { export type BottomTabNavigatorParams = CommonNavigatorParams & {
@ -69,7 +68,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
} }
export type NotificationsTabNavigatorParams = CommonNavigatorParams & { export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: undefined Notifications: {show?: 'all'}
} }
export type MyProfileTabNavigatorParams = CommonNavigatorParams & { export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
@ -84,7 +83,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined Home: undefined
Search: {q?: string} Search: {q?: string}
Feeds: undefined Feeds: undefined
Notifications: undefined Notifications: {show?: 'all'}
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'} Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
} }
@ -96,7 +95,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Search: {q?: string} Search: {q?: string}
Feeds: undefined Feeds: undefined
NotificationsTab: undefined NotificationsTab: undefined
Notifications: undefined Notifications: {show?: 'all'}
MyProfileTab: undefined MyProfileTab: undefined
Hashtag: {tag: string; author?: string} Hashtag: {tag: string; author?: string}
MessagesTab: undefined MessagesTab: undefined
@ -105,9 +104,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
StarterPack: {name: string; rkey: string; new?: boolean} StarterPack: {name: string; rkey: string; new?: boolean}
StarterPackShort: {code: string} StarterPackShort: {code: string}
StarterPackWizard: undefined StarterPackWizard: undefined
StarterPackEdit: { StarterPackEdit: {rkey?: string}
rkey?: string
}
} }
// NOTE // NOTE

View File

@ -5,6 +5,7 @@ export const router = new Router({
Search: '/search', Search: '/search',
Feeds: '/feeds', Feeds: '/feeds',
Notifications: '/notifications', Notifications: '/notifications',
NotificationsSettings: '/notifications/settings',
Settings: '/settings', Settings: '/settings',
LanguageSettings: '/settings/language', LanguageSettings: '/settings/language',
Lists: '/lists', Lists: '/lists',

View File

@ -106,6 +106,7 @@ function Inner() {
title={_(msg`Something went wrong`)} title={_(msg`Something went wrong`)}
message={_(msg`We couldn't load this conversation`)} message={_(msg`We couldn't load this conversation`)}
onRetry={() => convoState.error.retry()} onRetry={() => convoState.error.retry()}
sideBorders={false}
/> />
</CenteredView> </CenteredView>
) )

View File

@ -309,7 +309,7 @@ function DesktopHeader({
a.gap_lg, a.gap_lg,
a.px_lg, a.px_lg,
a.pr_md, a.pr_md,
a.py_md, a.py_sm,
a.border_b, a.border_b,
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
]}> ]}>

View File

@ -107,7 +107,7 @@ export function MessagesSettingsScreen({}: Props) {
a.rounded_md, a.rounded_md,
t.atoms.bg_contrast_25, t.atoms.bg_contrast_25,
]}> ]}>
<Text style={[t.atoms.text_contrast_high]}> <Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans> <Trans>
You can continue ongoing conversations regardless of which setting You can continue ongoing conversations regardless of which setting
you choose. you choose.

View File

@ -46,11 +46,14 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined type RQPageParam = string | undefined
const RQKEY_ROOT = 'notification-feed' const RQKEY_ROOT = 'notification-feed'
export function RQKEY() { export function RQKEY(priority?: false) {
return [RQKEY_ROOT] return [RQKEY_ROOT, priority]
} }
export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { export function useNotificationFeedQuery(opts?: {
enabled?: boolean
overridePriorityNotifications?: boolean
}) {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@ -59,6 +62,10 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
const lastPageCountRef = useRef(0) const lastPageCountRef = useRef(0)
const gate = useGate() const gate = useGate()
// false: force showing all notifications
// undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined
const query = useInfiniteQuery< const query = useInfiniteQuery<
FeedPage, FeedPage,
Error, Error,
@ -67,7 +74,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
RQPageParam RQPageParam
>({ >({
staleTime: STALE.INFINITY, staleTime: STALE.INFINITY,
queryKey: RQKEY(), queryKey: RQKEY(priority),
async queryFn({pageParam}: {pageParam: RQPageParam}) { async queryFn({pageParam}: {pageParam: RQPageParam}) {
let page let page
if (!pageParam) { if (!pageParam) {
@ -75,8 +82,7 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
page = unreads.getCachedUnreadPage() page = unreads.getCachedUnreadPage()
} }
if (!page) { if (!page) {
page = ( const {page: fetchedPage} = await fetchPage({
await fetchPage({
agent, agent,
limit: PAGE_SIZE, limit: PAGE_SIZE,
cursor: pageParam, cursor: pageParam,
@ -84,8 +90,9 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
moderationOpts, moderationOpts,
fetchAdditionalData: true, fetchAdditionalData: true,
shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'), shouldUngroupFollowBacks: () => gate('ungroup_follow_backs'),
priority,
}) })
).page page = fetchedPage
} }
// if the first page has an unread, mark all read // if the first page has an unread, mark all read

View File

@ -0,0 +1,67 @@
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useMutation, useQueryClient} from '@tanstack/react-query'
import {until} from '#/lib/async/until'
import {logger} from '#/logger'
import {RQKEY as RQKEY_NOTIFS} from '#/state/queries/notifications/feed'
import {useAgent} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
export function useNotificationsSettingsMutation() {
const {_} = useLingui()
const agent = useAgent()
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (keys: string[]) => {
const enabled = keys[0] === 'enabled'
await agent.api.app.bsky.notification.putPreferences({
priority: enabled,
})
await until(
5, // 5 tries
1e3, // 1s delay between tries
res => res.data.priority === enabled,
() => agent.api.app.bsky.notification.listNotifications({limit: 1}),
)
eagerlySetCachedPriority(queryClient, enabled)
},
onError: err => {
logger.error('Failed to save notification preferences', {
safeMessage: err,
})
Toast.show(
_(msg`Failed to save notification preferences, please try again`),
'xmark',
)
},
onSuccess: () => {
Toast.show(_(msg`Preference saved`))
},
onSettled: () => {
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
},
})
}
function eagerlySetCachedPriority(
queryClient: ReturnType<typeof useQueryClient>,
enabled: boolean,
) {
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
if (!old) return old
return {
...old,
pages: old.pages.map((page: any) => {
return {
...page,
priority: enabled,
}
}),
}
})
}

View File

@ -22,6 +22,7 @@ export interface FeedPage {
cursor: string | undefined cursor: string | undefined
seenAt: Date seenAt: Date
items: FeedNotification[] items: FeedNotification[]
priority: boolean
} }
export interface CachedFeedPage { export interface CachedFeedPage {

View File

@ -39,10 +39,15 @@ export async function fetchPage({
moderationOpts: ModerationOpts | undefined moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean fetchAdditionalData: boolean
shouldUngroupFollowBacks?: () => boolean shouldUngroupFollowBacks?: () => boolean
}): Promise<{page: FeedPage; indexedAt: string | undefined}> { priority?: boolean
}): Promise<{
page: FeedPage
indexedAt: string | undefined
}> {
const res = await agent.listNotifications({ const res = await agent.listNotifications({
limit, limit,
cursor, cursor,
// priority,
}) })
const indexedAt = res.data.notifications[0]?.indexedAt const indexedAt = res.data.notifications[0]?.indexedAt
@ -88,6 +93,7 @@ export async function fetchPage({
cursor: res.data.cursor, cursor: res.data.cursor,
seenAt, seenAt,
items: notifsGrouped, items: notifsGrouped,
priority: res.data.priority ?? false,
}, },
indexedAt, indexedAt,
} }

View File

@ -35,11 +35,13 @@ export function Feed({
onPressTryAgain, onPressTryAgain,
onScrolledDownChange, onScrolledDownChange,
ListHeaderComponent, ListHeaderComponent,
overridePriorityNotifications,
}: { }: {
scrollElRef?: ListRef scrollElRef?: ListRef
onPressTryAgain?: () => void onPressTryAgain?: () => void
onScrolledDownChange: (isScrolledDown: boolean) => void onScrolledDownChange: (isScrolledDown: boolean) => void
ListHeaderComponent?: () => JSX.Element ListHeaderComponent?: () => JSX.Element
overridePriorityNotifications?: boolean
}) { }) {
const initialNumToRender = useInitialNumToRender() const initialNumToRender = useInitialNumToRender()
@ -59,7 +61,10 @@ export function Feed({
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
} = useNotificationFeedQuery({enabled: !!moderationOpts}) } = useNotificationFeedQuery({
enabled: !!moderationOpts,
overridePriorityNotifications,
})
const isEmpty = !isFetching && !data?.pages[0]?.items.length const isEmpty = !isFetching && !data?.pages[0]?.items.length
const items = React.useMemo(() => { const items = React.useMemo(() => {

View File

@ -1,11 +1,19 @@
import React from 'react' import React, {useCallback} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useFocusEffect, useIsFocused} from '@react-navigation/native' import {useFocusEffect, useIsFocused} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query' import {useQueryClient} from '@tanstack/react-query'
import {useAnalytics} from '#/lib/analytics/analytics'
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {ComposeIcon2} from '#/lib/icons'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
} from '#/lib/routes/types'
import {s} from '#/lib/styles'
import {logger} from '#/logger' import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {emitSoftReset, listenSoftReset} from '#/state/events' import {emitSoftReset, listenSoftReset} from '#/state/events'
@ -17,37 +25,32 @@ import {
import {truncateAndInvalidate} from '#/state/queries/util' import {truncateAndInvalidate} from '#/state/queries/util'
import {useSetMinimalShellMode} from '#/state/shell' import {useSetMinimalShellMode} from '#/state/shell'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useAnalytics} from 'lib/analytics/analytics' import {Feed} from '#/view/com/notifications/Feed'
import {usePalette} from 'lib/hooks/usePalette' import {FAB} from '#/view/com/util/fab/FAB'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {MainScrollProvider} from '#/view/com/util/MainScrollProvider'
import {ComposeIcon2} from 'lib/icons' import {ViewHeader} from '#/view/com/util/ViewHeader'
import {
NativeStackScreenProps,
NotificationsTabNavigatorParams,
} from 'lib/routes/types'
import {colors, s} from 'lib/styles'
import {TextLink} from 'view/com/util/Link'
import {ListMethods} from 'view/com/util/List' import {ListMethods} from 'view/com/util/List'
import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn' import {LoadLatestBtn} from 'view/com/util/load-latest/LoadLatestBtn'
import {CenteredView} from 'view/com/util/Views' import {CenteredView} from 'view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2'
import {Link} from '#/components/Link'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Feed} from '../com/notifications/Feed' import {Text} from '#/components/Typography'
import {FAB} from '../com/util/fab/FAB'
import {MainScrollProvider} from '../com/util/MainScrollProvider'
import {ViewHeader} from '../com/util/ViewHeader'
type Props = NativeStackScreenProps< type Props = NativeStackScreenProps<
NotificationsTabNavigatorParams, NotificationsTabNavigatorParams,
'Notifications' 'Notifications'
> >
export function NotificationsScreen({}: Props) { export function NotificationsScreen({route: {params}}: Props) {
const {_} = useLingui() const {_} = useLingui()
const setMinimalShellMode = useSetMinimalShellMode() const setMinimalShellMode = useSetMinimalShellMode()
const [isScrolledDown, setIsScrolledDown] = React.useState(false) const [isScrolledDown, setIsScrolledDown] = React.useState(false)
const [isLoadingLatest, setIsLoadingLatest] = React.useState(false) const [isLoadingLatest, setIsLoadingLatest] = React.useState(false)
const scrollElRef = React.useRef<ListMethods>(null) const scrollElRef = React.useRef<ListMethods>(null)
const {screen} = useAnalytics() const {screen} = useAnalytics()
const pal = usePalette('default') const t = useTheme()
const {isDesktop} = useWebMediaQueries() const {isDesktop} = useWebMediaQueries()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const unreadNotifs = useUnreadNotifications() const unreadNotifs = useUnreadNotifications()
@ -109,56 +112,87 @@ export function NotificationsScreen({}: Props) {
return listenSoftReset(onPressLoadLatest) return listenSoftReset(onPressLoadLatest)
}, [onPressLoadLatest, isScreenFocused]) }, [onPressLoadLatest, isScreenFocused])
const renderButton = useCallback(() => {
return (
<Link
to="/notifications/settings"
label={_(msg`Notification settings`)}
size="small"
variant="ghost"
color="secondary"
shape="square"
style={[a.justify_center]}>
<SettingsIcon size="md" style={t.atoms.text_contrast_medium} />
</Link>
)
}, [_, t])
const ListHeaderComponent = React.useCallback(() => { const ListHeaderComponent = React.useCallback(() => {
if (isDesktop) { if (isDesktop) {
return ( return (
<View <View
style={[ style={[
pal.view, t.atoms.bg,
{ a.flex_row,
flexDirection: 'row', a.align_center,
alignItems: 'center', a.justify_between,
justifyContent: 'space-between', a.gap_lg,
paddingHorizontal: 18, a.px_lg,
paddingVertical: 12, a.pr_md,
}, a.py_sm,
]}> ]}>
<TextLink <Button
type="title-lg" label={_(msg`Notifications`)}
href="/notifications" accessibilityHint={_(msg`Refresh notifications`)}
style={[pal.text, {fontWeight: 'bold'}]} onPress={emitSoftReset}>
text={ {({hovered, pressed}) => (
<> <Text
<Trans>Notifications</Trans>{' '} style={[
a.text_2xl,
a.font_bold,
(hovered || pressed) && a.underline,
]}>
<Trans>Notifications</Trans>
{hasNew && ( {hasNew && (
<View <View
style={{ style={{
left: 4,
top: -8, top: -8,
backgroundColor: colors.blue3, backgroundColor: t.palette.primary_500,
width: 8, width: 8,
height: 8, height: 8,
borderRadius: 4, borderRadius: 4,
}} }}
/> />
)} )}
</> </Text>
} )}
onPress={emitSoftReset} </Button>
/> <View style={[a.flex_row, a.align_center, a.gap_sm]}>
{isLoadingLatest ? <Loader size="md" /> : <></>} {isLoadingLatest ? <Loader size="md" /> : <></>}
{renderButton()}
</View>
</View> </View>
) )
} }
return <></> return <></>
}, [isDesktop, pal, hasNew, isLoadingLatest]) }, [isDesktop, t, hasNew, renderButton, _, isLoadingLatest])
const renderHeaderSpinner = React.useCallback(() => { const renderHeaderSpinner = React.useCallback(() => {
return ( return (
<View style={{width: 30, height: 20, alignItems: 'flex-end'}}> <View
style={[
{width: 30, height: 20},
a.flex_row,
a.align_center,
a.justify_end,
a.gap_md,
]}>
{isLoadingLatest ? <Loader width={20} /> : <></>} {isLoadingLatest ? <Loader width={20} /> : <></>}
{renderButton()}
</View> </View>
) )
}, [isLoadingLatest]) }, [renderButton, isLoadingLatest])
return ( return (
<CenteredView <CenteredView
@ -176,6 +210,7 @@ export function NotificationsScreen({}: Props) {
onScrolledDownChange={setIsScrolledDown} onScrolledDownChange={setIsScrolledDown}
scrollElRef={scrollElRef} scrollElRef={scrollElRef}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}
overridePriorityNotifications={params?.show === 'all'}
/> />
</MainScrollProvider> </MainScrollProvider>
{(isScrolledDown || hasNew) && ( {(isScrolledDown || hasNew) && (

View File

@ -0,0 +1,94 @@
import React from 'react'
import {View} from 'react-native'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {AllNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {useNotificationsSettingsMutation} from '#/state/queries/notifications/settings'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a, useTheme} from '#/alf'
import {Error} from '#/components/Error'
import * as Toggle from '#/components/forms/Toggle'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography'
type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationsSettings'>
export function NotificationsSettingsScreen({}: Props) {
const {_} = useLingui()
const t = useTheme()
const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
const serverPriority = data?.pages.at(0)?.priority
const {
mutate: onChangePriority,
isPending: isMutationPending,
variables,
} = useNotificationsSettingsMutation()
const priority = isMutationPending
? variables[0] === 'enabled'
: serverPriority
return (
<CenteredView style={a.flex_1} sideBorders>
<ViewHeader
title={_(msg`Notification Settings`)}
showOnDesktop
showBorder
/>
{isQueryError ? (
<Error
title={_(msg`Oops!`)}
message={_(msg`Something went wrong!`)}
onRetry={refetch}
sideBorders={false}
/>
) : (
<View style={[a.p_lg, a.gap_md]}>
<Text style={[a.text_lg, a.font_bold]}>
<FontAwesomeIcon icon="flask" style={t.atoms.text} />{' '}
<Trans>Notification filters</Trans>
</Text>
<Toggle.Group
label={_(msg`Priority notifications`)}
type="checkbox"
values={priority ? ['enabled'] : []}
onChange={onChangePriority}
disabled={typeof priority !== 'boolean' || isMutationPending}>
<View>
<Toggle.Item
name="enabled"
label={_(msg`Enable priority notifications`)}
style={[a.justify_between, a.py_sm]}>
<Toggle.LabelText>
<Trans>Enable priority notifications</Trans>
</Toggle.LabelText>
{!data ? <Loader size="md" /> : <Toggle.Platform />}
</Toggle.Item>
</View>
</Toggle.Group>
<View
style={[
a.mt_sm,
a.px_xl,
a.py_lg,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>
Experimental: When this preference is enabled, you'll only
receive reply and quote notifications from users you follow.
We'll continue to add more controls here over time.
</Trans>
</Text>
</View>
</View>
)}
</CenteredView>
)
}

View File

@ -34,10 +34,10 @@
jsonpointer "^5.0.0" jsonpointer "^5.0.0"
leven "^3.1.0" leven "^3.1.0"
"@atproto/api@^0.12.23": "@atproto/api@0.12.25":
version "0.12.23" version "0.12.25"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.23.tgz#b3409817d0b981a64f30d16e8257f0fe261338af" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.25.tgz#9eeb51484106a5e07f89f124e505674a3574f93b"
integrity sha512-fgQ30u+q9smX5g41eep7fISSkSAhRkX0inc81PZ82QwcHbFkC8ePaha/KP0CoTaPWKi7EsC89Z/8BEBCJo0oBA== integrity sha512-IV3vGPnDw9bmyP/JOd8YKbm8fOpRAgJpEUVnIZNVb/Vo8v+WOroOjrJxtzdHOcXTL9IEcTTyXSCc7yE7kwhN2A==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.0" "@atproto/lexicon" "^0.4.0"