More notifications improvements (#2198)
* On mobile, never replace the notifs under the user due to focus events * Use the server's seenAt response to calculate isRead state locallyzio/stable
parent
eecf04489f
commit
e3ba014be0
|
@ -35,7 +35,7 @@
|
||||||
"intl:compile": "lingui compile"
|
"intl:compile": "lingui compile"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.7.2",
|
"@atproto/api": "^0.7.3",
|
||||||
"@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",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
|
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {useEffect, useRef} from 'react'
|
import {useEffect} from 'react'
|
||||||
import {AppBskyFeedDefs} from '@atproto/api'
|
import {AppBskyFeedDefs} from '@atproto/api'
|
||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
|
@ -49,8 +49,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
const threadMutes = useMutedThreads()
|
const threadMutes = useMutedThreads()
|
||||||
const unreads = useUnreadNotificationsApi()
|
const unreads = useUnreadNotificationsApi()
|
||||||
const enabled = opts?.enabled !== false
|
const enabled = opts?.enabled !== false
|
||||||
// state tracked across page fetches
|
|
||||||
const pageState = useRef({pageNum: 0, hasMarkedRead: false})
|
|
||||||
|
|
||||||
const query = useInfiniteQuery<
|
const query = useInfiniteQuery<
|
||||||
FeedPage,
|
FeedPage,
|
||||||
|
@ -66,8 +64,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
if (!pageParam) {
|
if (!pageParam) {
|
||||||
// for the first page, we check the cached page held by the unread-checker first
|
// for the first page, we check the cached page held by the unread-checker first
|
||||||
page = unreads.getCachedUnreadPage()
|
page = unreads.getCachedUnreadPage()
|
||||||
// reset the page state
|
|
||||||
pageState.current = {pageNum: 0, hasMarkedRead: false}
|
|
||||||
}
|
}
|
||||||
if (!page) {
|
if (!page) {
|
||||||
page = await fetchPage({
|
page = await fetchPage({
|
||||||
|
@ -80,27 +76,9 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE
|
// if the first page has an unread, mark all read
|
||||||
// this section checks to see if we need to mark notifs read
|
if (!pageParam && page.items[0] && !page.items[0].notification.isRead) {
|
||||||
// we want to wait until we've seen a read notification because
|
|
||||||
// of a timing challenge; marking read on the first page would
|
|
||||||
// cause subsequent pages of unread notifs to incorrectly come
|
|
||||||
// back as "read". we use page 6 as an abort condition, which means
|
|
||||||
// after ~180 notifs we give up on tracking unread state correctly
|
|
||||||
// -prf
|
|
||||||
if (!pageState.current.hasMarkedRead) {
|
|
||||||
let hasMarkedRead = false
|
|
||||||
if (
|
|
||||||
pageState.current.pageNum > 5 ||
|
|
||||||
page.items.some(item => item.notification.isRead)
|
|
||||||
) {
|
|
||||||
unreads.markAllRead()
|
unreads.markAllRead()
|
||||||
hasMarkedRead = true
|
|
||||||
}
|
|
||||||
pageState.current = {
|
|
||||||
pageNum: pageState.current.pageNum + 1,
|
|
||||||
hasMarkedRead,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return page
|
return page
|
||||||
|
@ -108,6 +86,20 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
|
||||||
initialPageParam: undefined,
|
initialPageParam: undefined,
|
||||||
getNextPageParam: lastPage => lastPage.cursor,
|
getNextPageParam: lastPage => lastPage.cursor,
|
||||||
enabled,
|
enabled,
|
||||||
|
select(data: InfiniteData<FeedPage>) {
|
||||||
|
// override 'isRead' using the first page's returned seenAt
|
||||||
|
// we do this because the `markAllRead()` call above will
|
||||||
|
// mark subsequent pages as read prematurely
|
||||||
|
const seenAt = data.pages[0]?.seenAt || new Date()
|
||||||
|
for (const page of data.pages) {
|
||||||
|
for (const item of page.items) {
|
||||||
|
item.notification.isRead =
|
||||||
|
seenAt > new Date(item.notification.indexedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return data
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -24,6 +24,7 @@ export interface FeedNotification {
|
||||||
|
|
||||||
export interface FeedPage {
|
export interface FeedPage {
|
||||||
cursor: string | undefined
|
cursor: string | undefined
|
||||||
|
seenAt: Date
|
||||||
items: FeedNotification[]
|
items: FeedNotification[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -68,8 +68,14 @@ export async function fetchPage({
|
||||||
notif => !isThreadMuted(notif, threadMutes),
|
notif => !isThreadMuted(notif, threadMutes),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
|
||||||
|
if (Number.isNaN(seenAt.getTime())) {
|
||||||
|
seenAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cursor: res.data.cursor,
|
cursor: res.data.cursor,
|
||||||
|
seenAt,
|
||||||
items: notifsGrouped,
|
items: notifsGrouped,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -67,8 +67,8 @@ export function NotificationsScreen({}: Props) {
|
||||||
const onFocusCheckLatest = React.useCallback(() => {
|
const onFocusCheckLatest = React.useCallback(() => {
|
||||||
// on focus, check for latest, but only invalidate if the user
|
// on focus, check for latest, but only invalidate if the user
|
||||||
// isnt scrolled down to avoid moving content underneath them
|
// isnt scrolled down to avoid moving content underneath them
|
||||||
unreadApi.checkUnread({invalidate: !isScrolledDown})
|
unreadApi.checkUnread({invalidate: !isScrolledDown && isDesktop})
|
||||||
}, [unreadApi, isScrolledDown])
|
}, [unreadApi, isScrolledDown, isDesktop])
|
||||||
checkLatestRef.current = onFocusCheckLatest
|
checkLatestRef.current = onFocusCheckLatest
|
||||||
|
|
||||||
// on-visible setup
|
// on-visible setup
|
||||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -48,6 +48,20 @@
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
zod "^3.21.4"
|
zod "^3.21.4"
|
||||||
|
|
||||||
|
"@atproto/api@^0.7.3":
|
||||||
|
version "0.7.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.7.3.tgz#3224000353619970d5e397a157c6e189e195ef47"
|
||||||
|
integrity sha512-fKU+W+S4kKxClE6IcPBHPZAjcyBYxG28S0FW/bv3T/ZYDkNxGzDV4xuoHOyEDGtB30slltl5U83njuuRZs5xtw==
|
||||||
|
dependencies:
|
||||||
|
"@atproto/common-web" "^0.2.3"
|
||||||
|
"@atproto/lexicon" "^0.3.1"
|
||||||
|
"@atproto/syntax" "^0.1.5"
|
||||||
|
"@atproto/xrpc" "^0.4.1"
|
||||||
|
multiformats "^9.9.0"
|
||||||
|
tlds "^1.234.0"
|
||||||
|
typed-emitter "^2.1.0"
|
||||||
|
zod "^3.21.4"
|
||||||
|
|
||||||
"@atproto/aws@^0.1.6":
|
"@atproto/aws@^0.1.6":
|
||||||
version "0.1.6"
|
version "0.1.6"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.1.6.tgz#c6ecbfd92b325f3c5433688534d47f43358b415b"
|
resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.1.6.tgz#c6ecbfd92b325f3c5433688534d47f43358b415b"
|
||||||
|
|
Loading…
Reference in New Issue