Skip to content

Commit

Permalink
[Notifications] Add a Mentions tab (#7044)
Browse files Browse the repository at this point in the history
* Split out NotificationsTab

* Remove unused route parameter

* Refine the split between components

* Hoist some logic out of NotificationFeed

* Remove unused option

* Add all|conversations to query, hardcode "all"

* Add a Conversations tab

* Rename to Mentions

* Bump packages

* Rename fields

* Fix oopsie

* Simplify header

* Track active tab

* Fix types

* Separate logic for tabs

* Better border for first unread

* Highlight unread for all only

* Fix spinner races

* Fix fetchPage races

* Fix bottom bar border being obscured by glimmer

* Remember last tab within the session

* One tab at a time

* Fix TS

* Handle all RQKEY usages

* Nit
  • Loading branch information
gaearon authored Dec 12, 2024
1 parent 10e241e commit f8cdd6b
Show file tree
Hide file tree
Showing 15 changed files with 409 additions and 267 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"icons:optimize": "svgo -f ./assets/icons"
},
"dependencies": {
"@atproto/api": "^0.13.18",
"@atproto/api": "^0.13.20",
"@bitdrift/react-native": "0.4.0",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
Expand Down Expand Up @@ -206,7 +206,7 @@
"zod": "^3.20.2"
},
"devDependencies": {
"@atproto/dev-env": "^0.3.64",
"@atproto/dev-env": "^0.3.67",
"@babel/core": "^7.26.0",
"@babel/preset-env": "^7.26.0",
"@babel/runtime": "^7.26.0",
Expand Down
15 changes: 11 additions & 4 deletions src/lib/hooks/useNotificationHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,14 +239,21 @@ export function useNotificationsHandler() {
)
logEvent('notifications:openApp', {})
invalidateCachedUnreadPage()
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
const payload = e.notification.request.trigger
.payload as NotificationPayload
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
if (
payload.reason === 'mention' ||
payload.reason === 'quote' ||
payload.reason === 'reply'
) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
logger.debug('Notifications: handleNotification', {
content: e.notification.request.content,
payload: e.notification.request.trigger.payload,
})
handleNotification(
e.notification.request.trigger.payload as NotificationPayload,
)
handleNotification(payload)
Notifications.dismissAllNotificationsAsync()
}
})
Expand Down
6 changes: 3 additions & 3 deletions src/lib/routes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
}

export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
Notifications: {show?: 'all'}
Notifications: undefined
}

export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
Expand All @@ -90,7 +90,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
Home: undefined
Search: {q?: string}
Feeds: undefined
Notifications: {show?: 'all'}
Notifications: undefined
Hashtag: {tag: string; author?: string}
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
}
Expand All @@ -102,7 +102,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
Search: {q?: string}
Feeds: undefined
NotificationsTab: undefined
Notifications: {show?: 'all'}
Notifications: undefined
MyProfileTab: undefined
Hashtag: {tag: string; author?: string}
MessagesTab: undefined
Expand Down
8 changes: 7 additions & 1 deletion src/screens/Settings/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
export function NotificationSettingsScreen({}: Props) {
const {_} = useLingui()

const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
const {
data,
isError: isQueryError,
refetch,
} = useNotificationFeedQuery({
filter: 'all',
})
const serverPriority = data?.pages.at(0)?.priority

const {
Expand Down
34 changes: 20 additions & 14 deletions src/state/queries/notifications/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,25 +52,22 @@ const PAGE_SIZE = 30
type RQPageParam = string | undefined

const RQKEY_ROOT = 'notification-feed'
export function RQKEY(priority?: false) {
return [RQKEY_ROOT, priority]
export function RQKEY(filter: 'all' | 'mentions') {
return [RQKEY_ROOT, filter]
}

export function useNotificationFeedQuery(opts?: {
export function useNotificationFeedQuery(opts: {
enabled?: boolean
overridePriorityNotifications?: boolean
filter: 'all' | 'mentions'
}) {
const agent = useAgent()
const queryClient = useQueryClient()
const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
const enabled = opts.enabled !== false
const filter = opts.filter
const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()

// false: force showing all notifications
// undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined

const selectArgs = useMemo(() => {
return {
moderationOpts,
Expand All @@ -91,28 +88,37 @@ export function useNotificationFeedQuery(opts?: {
RQPageParam
>({
staleTime: STALE.INFINITY,
queryKey: RQKEY(priority),
queryKey: RQKEY(filter),
async queryFn({pageParam}: {pageParam: RQPageParam}) {
let page
if (!pageParam) {
if (filter === 'all' && !pageParam) {
// for the first page, we check the cached page held by the unread-checker first
page = unreads.getCachedUnreadPage()
}
if (!page) {
let reasons: string[] = []
if (filter === 'mentions') {
reasons = [
// Anything that's a post
'mention',
'reply',
'quote',
]
}
const {page: fetchedPage} = await fetchPage({
agent,
limit: PAGE_SIZE,
cursor: pageParam,
queryClient,
moderationOpts,
fetchAdditionalData: true,
priority,
reasons,
})
page = fetchedPage
}

// if the first page has an unread, mark all read
if (!pageParam) {
if (filter === 'all' && !pageParam) {
// if the first page has an unread, mark all read
unreads.markAllRead()
}

Expand Down
9 changes: 6 additions & 3 deletions src/state/queries/notifications/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function useNotificationSettingsMutation() {
},
onSettled: () => {
invalidateCachedUnreadPage()
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('all')})
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('mentions')})
},
})
}
Expand All @@ -54,7 +55,7 @@ function eagerlySetCachedPriority(
queryClient: ReturnType<typeof useQueryClient>,
enabled: boolean,
) {
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
function updateData(old: any) {
if (!old) return old
return {
...old,
Expand All @@ -65,5 +66,7 @@ function eagerlySetCachedPriority(
}
}),
}
})
}
queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData)
queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData)
}
16 changes: 14 additions & 2 deletions src/state/queries/notifications/unread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* A kind of companion API to ./feed.ts. See that file for more info.
*/

import React from 'react'
import React, {useRef} from 'react'
import {AppState} from 'react-native'
import {useQueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
Expand Down Expand Up @@ -105,6 +105,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
}, [setNumUnread])

const isFetchingRef = useRef(false)

// create API
const api = React.useMemo<ApiContext>(() => {
return {
Expand Down Expand Up @@ -138,13 +140,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
}
}

if (isFetchingRef.current) {
return
}
// Do not move this without ensuring it gets a symmetrical reset in the finally block.
isFetchingRef.current = true

// count
const {page, indexedAt: lastIndexed} = await fetchPage({
agent,
cursor: undefined,
limit: 40,
queryClient,
moderationOpts,
reasons: [],

// only fetch subjects when the page is going to be used
// in the notifications query, otherwise skip it
Expand Down Expand Up @@ -174,11 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// update & broadcast
setNumUnread(unreadCountStr)
if (invalidate) {
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
}
broadcast.postMessage({event: unreadCountStr})
} catch (e) {
logger.warn('Failed to check unread notifications', {error: e})
} finally {
isFetchingRef.current = false
}
},

Expand Down
5 changes: 3 additions & 2 deletions src/state/queries/notifications/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,22 +31,23 @@ export async function fetchPage({
queryClient,
moderationOpts,
fetchAdditionalData,
reasons,
}: {
agent: BskyAgent
cursor: string | undefined
limit: number
queryClient: QueryClient
moderationOpts: ModerationOpts | undefined
fetchAdditionalData: boolean
priority?: boolean
reasons: string[]
}): Promise<{
page: FeedPage
indexedAt: string | undefined
}> {
const res = await agent.listNotifications({
limit,
cursor,
// priority,
reasons,
})

const indexedAt = res.data.notifications[0]?.indexedAt
Expand Down
4 changes: 2 additions & 2 deletions src/state/queries/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from '@atproto/api'
import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'

export function truncateAndInvalidate<T = any>(
export async function truncateAndInvalidate<T = any>(
queryClient: QueryClient,
queryKey: QueryKey,
) {
Expand All @@ -21,7 +21,7 @@ export function truncateAndInvalidate<T = any>(
}
return data
})
queryClient.invalidateQueries({queryKey})
return queryClient.invalidateQueries({queryKey})
}

// Given an AtUri, this function will check if the AtUri matches a
Expand Down
33 changes: 14 additions & 19 deletions src/view/com/notifications/NotificationFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'

import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
import {usePalette} from '#/lib/hooks/usePalette'
import {cleanError} from '#/lib/strings/errors'
import {s} from '#/lib/styles'
import {logger} from '#/logger'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
import {EmptyState} from '#/view/com/util/EmptyState'
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
import {List, ListRef} from '#/view/com/util/List'
Expand All @@ -28,26 +26,26 @@ const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
const LOADING_ITEM = {_reactKey: '__loading__'}

export function NotificationFeed({
filter,
enabled,
scrollElRef,
onPressTryAgain,
onScrolledDownChange,
ListHeaderComponent,
overridePriorityNotifications,
refreshNotifications,
}: {
filter: 'all' | 'mentions'
enabled: boolean
scrollElRef?: ListRef
onPressTryAgain?: () => void
onScrolledDownChange: (isScrolledDown: boolean) => void
ListHeaderComponent?: () => JSX.Element
overridePriorityNotifications?: boolean
refreshNotifications: () => Promise<void>
}) {
const initialNumToRender = useInitialNumToRender()

const [isPTRing, setIsPTRing] = React.useState(false)
const pal = usePalette('default')

const {_} = useLingui()
const moderationOpts = useModerationOpts()
const {checkUnread} = useUnreadNotificationsApi()
const {
data,
isFetching,
Expand All @@ -58,8 +56,8 @@ export function NotificationFeed({
isFetchingNextPage,
fetchNextPage,
} = useNotificationFeedQuery({
enabled: !!moderationOpts,
overridePriorityNotifications,
enabled: enabled && !!moderationOpts,
filter,
})
const isEmpty = !isFetching && !data?.pages[0]?.items.length

Expand All @@ -85,15 +83,15 @@ export function NotificationFeed({
const onRefresh = React.useCallback(async () => {
try {
setIsPTRing(true)
await checkUnread({invalidate: true})
await refreshNotifications()
} catch (err) {
logger.error('Failed to refresh notifications feed', {
message: err,
})
} finally {
setIsPTRing(false)
}
}, [checkUnread, setIsPTRing])
}, [refreshNotifications, setIsPTRing])

const onEndReached = React.useCallback(async () => {
if (isFetching || !hasNextPage || isError) return
Expand Down Expand Up @@ -129,21 +127,18 @@ export function NotificationFeed({
/>
)
} else if (item === LOADING_ITEM) {
return (
<View style={[pal.border]}>
<NotificationFeedLoadingPlaceholder />
</View>
)
return <NotificationFeedLoadingPlaceholder />
}
return (
<NotificationFeedItem
highlightUnread={filter === 'all'}
item={item}
moderationOpts={moderationOpts!}
hideTopBorder={index === 0}
hideTopBorder={index === 0 && item.notification.isRead}
/>
)
},
[moderationOpts, _, onPressRetryLoadMore, pal.border],
[moderationOpts, _, onPressRetryLoadMore, filter],
)

const FeedFooter = React.useCallback(
Expand Down
Loading

0 comments on commit f8cdd6b

Please sign in to comment.