Skip to content

Commit f8cdd6b

Browse files
authored
[Notifications] Add a Mentions tab (bluesky-social#7044)
* 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
1 parent 10e241e commit f8cdd6b

File tree

15 files changed

+409
-267
lines changed

15 files changed

+409
-267
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"icons:optimize": "svgo -f ./assets/icons"
5555
},
5656
"dependencies": {
57-
"@atproto/api": "^0.13.18",
57+
"@atproto/api": "^0.13.20",
5858
"@bitdrift/react-native": "0.4.0",
5959
"@braintree/sanitize-url": "^6.0.2",
6060
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
@@ -206,7 +206,7 @@
206206
"zod": "^3.20.2"
207207
},
208208
"devDependencies": {
209-
"@atproto/dev-env": "^0.3.64",
209+
"@atproto/dev-env": "^0.3.67",
210210
"@babel/core": "^7.26.0",
211211
"@babel/preset-env": "^7.26.0",
212212
"@babel/runtime": "^7.26.0",

src/lib/hooks/useNotificationHandler.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -239,14 +239,21 @@ export function useNotificationsHandler() {
239239
)
240240
logEvent('notifications:openApp', {})
241241
invalidateCachedUnreadPage()
242-
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
242+
const payload = e.notification.request.trigger
243+
.payload as NotificationPayload
244+
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
245+
if (
246+
payload.reason === 'mention' ||
247+
payload.reason === 'quote' ||
248+
payload.reason === 'reply'
249+
) {
250+
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
251+
}
243252
logger.debug('Notifications: handleNotification', {
244253
content: e.notification.request.content,
245254
payload: e.notification.request.trigger.payload,
246255
})
247-
handleNotification(
248-
e.notification.request.trigger.payload as NotificationPayload,
249-
)
256+
handleNotification(payload)
250257
Notifications.dismissAllNotificationsAsync()
251258
}
252259
})

src/lib/routes/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & {
7575
}
7676

7777
export type NotificationsTabNavigatorParams = CommonNavigatorParams & {
78-
Notifications: {show?: 'all'}
78+
Notifications: undefined
7979
}
8080

8181
export type MyProfileTabNavigatorParams = CommonNavigatorParams & {
@@ -90,7 +90,7 @@ export type FlatNavigatorParams = CommonNavigatorParams & {
9090
Home: undefined
9191
Search: {q?: string}
9292
Feeds: undefined
93-
Notifications: {show?: 'all'}
93+
Notifications: undefined
9494
Hashtag: {tag: string; author?: string}
9595
Messages: {pushToConversation?: string; animation?: 'push' | 'pop'}
9696
}
@@ -102,7 +102,7 @@ export type AllNavigatorParams = CommonNavigatorParams & {
102102
Search: {q?: string}
103103
Feeds: undefined
104104
NotificationsTab: undefined
105-
Notifications: {show?: 'all'}
105+
Notifications: undefined
106106
MyProfileTab: undefined
107107
Hashtag: {tag: string; author?: string}
108108
MessagesTab: undefined

src/screens/Settings/NotificationSettings.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ type Props = NativeStackScreenProps<AllNavigatorParams, 'NotificationSettings'>
1818
export function NotificationSettingsScreen({}: Props) {
1919
const {_} = useLingui()
2020

21-
const {data, isError: isQueryError, refetch} = useNotificationFeedQuery()
21+
const {
22+
data,
23+
isError: isQueryError,
24+
refetch,
25+
} = useNotificationFeedQuery({
26+
filter: 'all',
27+
})
2228
const serverPriority = data?.pages.at(0)?.priority
2329

2430
const {

src/state/queries/notifications/feed.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,25 +52,22 @@ const PAGE_SIZE = 30
5252
type RQPageParam = string | undefined
5353

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

59-
export function useNotificationFeedQuery(opts?: {
59+
export function useNotificationFeedQuery(opts: {
6060
enabled?: boolean
61-
overridePriorityNotifications?: boolean
61+
filter: 'all' | 'mentions'
6262
}) {
6363
const agent = useAgent()
6464
const queryClient = useQueryClient()
6565
const moderationOpts = useModerationOpts()
6666
const unreads = useUnreadNotificationsApi()
67-
const enabled = opts?.enabled !== false
67+
const enabled = opts.enabled !== false
68+
const filter = opts.filter
6869
const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
6970

70-
// false: force showing all notifications
71-
// undefined: let the server decide
72-
const priority = opts?.overridePriorityNotifications ? false : undefined
73-
7471
const selectArgs = useMemo(() => {
7572
return {
7673
moderationOpts,
@@ -91,28 +88,37 @@ export function useNotificationFeedQuery(opts?: {
9188
RQPageParam
9289
>({
9390
staleTime: STALE.INFINITY,
94-
queryKey: RQKEY(priority),
91+
queryKey: RQKEY(filter),
9592
async queryFn({pageParam}: {pageParam: RQPageParam}) {
9693
let page
97-
if (!pageParam) {
94+
if (filter === 'all' && !pageParam) {
9895
// for the first page, we check the cached page held by the unread-checker first
9996
page = unreads.getCachedUnreadPage()
10097
}
10198
if (!page) {
99+
let reasons: string[] = []
100+
if (filter === 'mentions') {
101+
reasons = [
102+
// Anything that's a post
103+
'mention',
104+
'reply',
105+
'quote',
106+
]
107+
}
102108
const {page: fetchedPage} = await fetchPage({
103109
agent,
104110
limit: PAGE_SIZE,
105111
cursor: pageParam,
106112
queryClient,
107113
moderationOpts,
108114
fetchAdditionalData: true,
109-
priority,
115+
reasons,
110116
})
111117
page = fetchedPage
112118
}
113119

114-
// if the first page has an unread, mark all read
115-
if (!pageParam) {
120+
if (filter === 'all' && !pageParam) {
121+
// if the first page has an unread, mark all read
116122
unreads.markAllRead()
117123
}
118124

src/state/queries/notifications/settings.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ export function useNotificationSettingsMutation() {
4545
},
4646
onSettled: () => {
4747
invalidateCachedUnreadPage()
48-
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS()})
48+
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('all')})
49+
queryClient.invalidateQueries({queryKey: RQKEY_NOTIFS('mentions')})
4950
},
5051
})
5152
}
@@ -54,7 +55,7 @@ function eagerlySetCachedPriority(
5455
queryClient: ReturnType<typeof useQueryClient>,
5556
enabled: boolean,
5657
) {
57-
queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => {
58+
function updateData(old: any) {
5859
if (!old) return old
5960
return {
6061
...old,
@@ -65,5 +66,7 @@ function eagerlySetCachedPriority(
6566
}
6667
}),
6768
}
68-
})
69+
}
70+
queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData)
71+
queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData)
6972
}

src/state/queries/notifications/unread.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* A kind of companion API to ./feed.ts. See that file for more info.
33
*/
44

5-
import React from 'react'
5+
import React, {useRef} from 'react'
66
import {AppState} from 'react-native'
77
import {useQueryClient} from '@tanstack/react-query'
88
import EventEmitter from 'eventemitter3'
@@ -105,6 +105,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
105105
}
106106
}, [setNumUnread])
107107

108+
const isFetchingRef = useRef(false)
109+
108110
// create API
109111
const api = React.useMemo<ApiContext>(() => {
110112
return {
@@ -138,13 +140,20 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
138140
}
139141
}
140142

143+
if (isFetchingRef.current) {
144+
return
145+
}
146+
// Do not move this without ensuring it gets a symmetrical reset in the finally block.
147+
isFetchingRef.current = true
148+
141149
// count
142150
const {page, indexedAt: lastIndexed} = await fetchPage({
143151
agent,
144152
cursor: undefined,
145153
limit: 40,
146154
queryClient,
147155
moderationOpts,
156+
reasons: [],
148157

149158
// only fetch subjects when the page is going to be used
150159
// in the notifications query, otherwise skip it
@@ -174,11 +183,14 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
174183
// update & broadcast
175184
setNumUnread(unreadCountStr)
176185
if (invalidate) {
177-
truncateAndInvalidate(queryClient, RQKEY_NOTIFS())
186+
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('all'))
187+
truncateAndInvalidate(queryClient, RQKEY_NOTIFS('mentions'))
178188
}
179189
broadcast.postMessage({event: unreadCountStr})
180190
} catch (e) {
181191
logger.warn('Failed to check unread notifications', {error: e})
192+
} finally {
193+
isFetchingRef.current = false
182194
}
183195
},
184196

src/state/queries/notifications/util.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,23 @@ export async function fetchPage({
3131
queryClient,
3232
moderationOpts,
3333
fetchAdditionalData,
34+
reasons,
3435
}: {
3536
agent: BskyAgent
3637
cursor: string | undefined
3738
limit: number
3839
queryClient: QueryClient
3940
moderationOpts: ModerationOpts | undefined
4041
fetchAdditionalData: boolean
41-
priority?: boolean
42+
reasons: string[]
4243
}): Promise<{
4344
page: FeedPage
4445
indexedAt: string | undefined
4546
}> {
4647
const res = await agent.listNotifications({
4748
limit,
4849
cursor,
49-
// priority,
50+
reasons,
5051
})
5152

5253
const indexedAt = res.data.notifications[0]?.indexedAt

src/state/queries/util.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
} from '@atproto/api'
99
import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query'
1010

11-
export function truncateAndInvalidate<T = any>(
11+
export async function truncateAndInvalidate<T = any>(
1212
queryClient: QueryClient,
1313
queryKey: QueryKey,
1414
) {
@@ -21,7 +21,7 @@ export function truncateAndInvalidate<T = any>(
2121
}
2222
return data
2323
})
24-
queryClient.invalidateQueries({queryKey})
24+
return queryClient.invalidateQueries({queryKey})
2525
}
2626

2727
// Given an AtUri, this function will check if the AtUri matches a

src/view/com/notifications/NotificationFeed.tsx

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,11 @@ import {msg} from '@lingui/macro'
99
import {useLingui} from '@lingui/react'
1010

1111
import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
12-
import {usePalette} from '#/lib/hooks/usePalette'
1312
import {cleanError} from '#/lib/strings/errors'
1413
import {s} from '#/lib/styles'
1514
import {logger} from '#/logger'
1615
import {useModerationOpts} from '#/state/preferences/moderation-opts'
1716
import {useNotificationFeedQuery} from '#/state/queries/notifications/feed'
18-
import {useUnreadNotificationsApi} from '#/state/queries/notifications/unread'
1917
import {EmptyState} from '#/view/com/util/EmptyState'
2018
import {ErrorMessage} from '#/view/com/util/error/ErrorMessage'
2119
import {List, ListRef} from '#/view/com/util/List'
@@ -28,26 +26,26 @@ const LOAD_MORE_ERROR_ITEM = {_reactKey: '__load_more_error__'}
2826
const LOADING_ITEM = {_reactKey: '__loading__'}
2927

3028
export function NotificationFeed({
29+
filter,
30+
enabled,
3131
scrollElRef,
3232
onPressTryAgain,
3333
onScrolledDownChange,
3434
ListHeaderComponent,
35-
overridePriorityNotifications,
35+
refreshNotifications,
3636
}: {
37+
filter: 'all' | 'mentions'
38+
enabled: boolean
3739
scrollElRef?: ListRef
3840
onPressTryAgain?: () => void
3941
onScrolledDownChange: (isScrolledDown: boolean) => void
4042
ListHeaderComponent?: () => JSX.Element
41-
overridePriorityNotifications?: boolean
43+
refreshNotifications: () => Promise<void>
4244
}) {
4345
const initialNumToRender = useInitialNumToRender()
44-
4546
const [isPTRing, setIsPTRing] = React.useState(false)
46-
const pal = usePalette('default')
47-
4847
const {_} = useLingui()
4948
const moderationOpts = useModerationOpts()
50-
const {checkUnread} = useUnreadNotificationsApi()
5149
const {
5250
data,
5351
isFetching,
@@ -58,8 +56,8 @@ export function NotificationFeed({
5856
isFetchingNextPage,
5957
fetchNextPage,
6058
} = useNotificationFeedQuery({
61-
enabled: !!moderationOpts,
62-
overridePriorityNotifications,
59+
enabled: enabled && !!moderationOpts,
60+
filter,
6361
})
6462
const isEmpty = !isFetching && !data?.pages[0]?.items.length
6563

@@ -85,15 +83,15 @@ export function NotificationFeed({
8583
const onRefresh = React.useCallback(async () => {
8684
try {
8785
setIsPTRing(true)
88-
await checkUnread({invalidate: true})
86+
await refreshNotifications()
8987
} catch (err) {
9088
logger.error('Failed to refresh notifications feed', {
9189
message: err,
9290
})
9391
} finally {
9492
setIsPTRing(false)
9593
}
96-
}, [checkUnread, setIsPTRing])
94+
}, [refreshNotifications, setIsPTRing])
9795

9896
const onEndReached = React.useCallback(async () => {
9997
if (isFetching || !hasNextPage || isError) return
@@ -129,21 +127,18 @@ export function NotificationFeed({
129127
/>
130128
)
131129
} else if (item === LOADING_ITEM) {
132-
return (
133-
<View style={[pal.border]}>
134-
<NotificationFeedLoadingPlaceholder />
135-
</View>
136-
)
130+
return <NotificationFeedLoadingPlaceholder />
137131
}
138132
return (
139133
<NotificationFeedItem
134+
highlightUnread={filter === 'all'}
140135
item={item}
141136
moderationOpts={moderationOpts!}
142-
hideTopBorder={index === 0}
137+
hideTopBorder={index === 0 && item.notification.isRead}
143138
/>
144139
)
145140
},
146-
[moderationOpts, _, onPressRetryLoadMore, pal.border],
141+
[moderationOpts, _, onPressRetryLoadMore, filter],
147142
)
148143

149144
const FeedFooter = React.useCallback(

0 commit comments

Comments
 (0)