ad7d49ef8f
* add expo-contacts * add expo-sms * update copy * add basic settings screen * state machine, flow screen * phone input screen * otp screen * tweak spacing * resend code logic * add layoutanimationconfig * check availablility in settings screen * get contacts * matches screen * search, temp setup for matches UI * add a bunch of number parsing logic with libphonenumber * cast to looser type * rename sync to find FCF (Find Contacts Flow) * update geolocation hook * nicer design for settings screen * add completed state * up border contrast * update expo deps * add pending spinner * add country whitelist * add empty state screen * drop add more functionality * upload -> import * fix typo * fix permission string * copy updates * rm envelope icon * update sms copy * add inviteinfo component * woke is back * [Contacts] NUXes (#9515) * add a bunch of number parsing logic with libphonenumber * add banner nux * add announcement nux * native only nux * rm shitty animation * move isNative check * [Contacts] Onboarding step (#9489) * add a bunch of number parsing logic with libphonenumber * restructure onboarding to better support dynamic screens * integrate existing flow into onboarding * add intro step * lift state up to allow going back freely * gate onboarding by geo if unsupported country * add done button to standalone flow * [Contacts] Add `contact-match` notification type to feed (#9519) * add a bunch of number parsing logic with libphonenumber * add contact-joined notif type * update string, api package * Update NotificationFeedItem.tsx * Update NotificationFeedItem.tsx * Delete SyncContactsFlow.web.tsx * fix follow back btn for this case * [Contacts] API integration (#9487) * api integration for flow * copy tweak * tweaks after running it * wire up status page * rename toast * use api lib * rm temp code * maybe fix otp error * clear code on error/resend * add 1s delay to verify success * update package versions * Delete SyncContactsFlow.web.tsx * try and fix yarn.lock lint * even woker * fix uppercase friends * surfdude feedback Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> * rm log * allow resend on invalid code * Update GetContacts.tsx * overwrite country code if possible * fix Apple's Best Feature * devenv latest * disable bounces * interactive keyboard dismiss * copy changes * national format * allow resending immediately * move success state down a bit * Update FindContactsSettings.tsx * [Contacts] More onboarding changes (#9491) * integrate existing flow into onboarding * refreshed onboarding styles, rm stepper * center content on web * Add back dismiss button for internal onboarding * Import sort --------- Co-authored-by: Eric Bailey <git@esb.lol> * add matches query to shadow * add clockwise arrow * update status design, fix dismiss, fix queries * add metrics * add more comments * Update metrics.ts * refetch both queries on PTR * fix shadow state in matches page * reduce empty space at bottom * show contact info on matches * filter out contacts without numbers at an earlier stage * Error handling ✨ * get notifs working * filter out businesses * rm log * remove TODO from learn more links * try and exclude from web bundle --------- Co-authored-by: surfdude29 <149612116+surfdude29@users.noreply.github.com> Co-authored-by: Eric Bailey <git@esb.lol>
343 lines
11 KiB
TypeScript
343 lines
11 KiB
TypeScript
/**
|
|
* NOTE
|
|
* The ./unread.ts API:
|
|
*
|
|
* - Provides a `checkUnread()` function to sync with the server,
|
|
* - Periodically calls `checkUnread()`, and
|
|
* - Caches the first page of notifications.
|
|
*
|
|
* IMPORTANT: This query uses ./unread.ts's cache as its first page,
|
|
* IMPORTANT: which means the cache-freshness of this query is driven by the unread API.
|
|
*
|
|
* Follow these rules:
|
|
*
|
|
* 1. Call `checkUnread()` if you want to fetch latest in the background.
|
|
* 2. Call `checkUnread({invalidate: true})` if you want latest to sync into this query's results immediately.
|
|
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
|
|
*/
|
|
|
|
import {useCallback, useEffect, useMemo, useRef} from 'react'
|
|
import {
|
|
AppBskyFeedDefs,
|
|
AppBskyFeedPost,
|
|
AtUri,
|
|
moderatePost,
|
|
} from '@atproto/api'
|
|
import {
|
|
type InfiniteData,
|
|
type QueryClient,
|
|
type QueryKey,
|
|
useInfiniteQuery,
|
|
useQueryClient,
|
|
} from '@tanstack/react-query'
|
|
|
|
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
|
import {STALE} from '#/state/queries'
|
|
import {useAgent} from '#/state/session'
|
|
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
|
|
import type * as bsky from '#/types/bsky'
|
|
import {
|
|
didOrHandleUriMatches,
|
|
embedViewRecordToPostView,
|
|
getEmbeddedPost,
|
|
} from '../util'
|
|
import {type FeedPage} from './types'
|
|
import {useUnreadNotificationsApi} from './unread'
|
|
import {fetchPage} from './util'
|
|
|
|
export type {FeedNotification, FeedPage, NotificationType} from './types'
|
|
|
|
const PAGE_SIZE = 30
|
|
|
|
type RQPageParam = string | undefined
|
|
|
|
const RQKEY_ROOT = 'notification-feed'
|
|
export function RQKEY(filter: 'all' | 'mentions') {
|
|
return [RQKEY_ROOT, filter]
|
|
}
|
|
|
|
export function useNotificationFeedQuery(opts: {
|
|
enabled?: boolean
|
|
filter: 'all' | 'mentions'
|
|
}) {
|
|
const agent = useAgent()
|
|
const queryClient = useQueryClient()
|
|
const moderationOpts = useModerationOpts()
|
|
const unreads = useUnreadNotificationsApi()
|
|
const enabled = opts.enabled !== false
|
|
const filter = opts.filter
|
|
const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
|
|
|
|
const selectArgs = useMemo(() => {
|
|
return {
|
|
moderationOpts,
|
|
hiddenReplyUris,
|
|
}
|
|
}, [moderationOpts, hiddenReplyUris])
|
|
const lastRun = useRef<{
|
|
data: InfiniteData<FeedPage>
|
|
args: typeof selectArgs
|
|
result: InfiniteData<FeedPage>
|
|
} | null>(null)
|
|
|
|
const query = useInfiniteQuery<
|
|
FeedPage,
|
|
Error,
|
|
InfiniteData<FeedPage>,
|
|
QueryKey,
|
|
RQPageParam
|
|
>({
|
|
staleTime: STALE.INFINITY,
|
|
queryKey: RQKEY(filter),
|
|
async queryFn({pageParam}: {pageParam: RQPageParam}) {
|
|
let page
|
|
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,
|
|
reasons,
|
|
})
|
|
page = fetchedPage
|
|
}
|
|
|
|
if (filter === 'all' && !pageParam) {
|
|
// if the first page has an unread, mark all read
|
|
unreads.markAllRead()
|
|
}
|
|
|
|
return page
|
|
},
|
|
initialPageParam: undefined,
|
|
getNextPageParam: lastPage => lastPage.cursor,
|
|
enabled,
|
|
select: useCallback(
|
|
(data: InfiniteData<FeedPage>) => {
|
|
const {moderationOpts, hiddenReplyUris} = selectArgs
|
|
|
|
// Keep track of the last run and whether we can reuse
|
|
// some already selected pages from there.
|
|
let reusedPages = []
|
|
if (lastRun.current) {
|
|
const {
|
|
data: lastData,
|
|
args: lastArgs,
|
|
result: lastResult,
|
|
} = lastRun.current
|
|
let canReuse = true
|
|
for (let key in selectArgs) {
|
|
if (selectArgs.hasOwnProperty(key)) {
|
|
if ((selectArgs as any)[key] !== (lastArgs as any)[key]) {
|
|
// Can't do reuse anything if any input has changed.
|
|
canReuse = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (canReuse) {
|
|
for (let i = 0; i < data.pages.length; i++) {
|
|
if (data.pages[i] && lastData.pages[i] === data.pages[i]) {
|
|
reusedPages.push(lastResult.pages[i])
|
|
continue
|
|
}
|
|
// Stop as soon as pages stop matching up.
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
const result = {
|
|
...data,
|
|
pages: [
|
|
...reusedPages,
|
|
...data.pages.slice(reusedPages.length).map(page => {
|
|
return {
|
|
...page,
|
|
items: page.items
|
|
.filter(item => {
|
|
const isHiddenReply =
|
|
item.type === 'reply' &&
|
|
item.subjectUri &&
|
|
hiddenReplyUris.has(item.subjectUri)
|
|
return !isHiddenReply
|
|
})
|
|
.filter(item => {
|
|
if (
|
|
item.type === 'reply' ||
|
|
item.type === 'mention' ||
|
|
item.type === 'quote'
|
|
) {
|
|
/*
|
|
* The `isPostView` check will fail here bc we don't have
|
|
* a `$type` field on the `subject`. But if the nested
|
|
* `record` is a post, we know it's a post view.
|
|
*/
|
|
if (AppBskyFeedPost.isRecord(item.subject?.record)) {
|
|
const mod = moderatePost(item.subject, moderationOpts!)
|
|
if (mod.ui('contentList').filter) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}),
|
|
}
|
|
}),
|
|
],
|
|
}
|
|
|
|
lastRun.current = {data, result, args: selectArgs}
|
|
|
|
return result
|
|
},
|
|
[selectArgs],
|
|
),
|
|
})
|
|
|
|
// The server may end up returning an empty page, a page with too few items,
|
|
// or a page with items that end up getting filtered out. When we fetch pages,
|
|
// we'll keep track of how many items we actually hope to see. If the server
|
|
// doesn't return enough items, we're going to continue asking for more items.
|
|
const lastItemCount = useRef(0)
|
|
const wantedItemCount = useRef(0)
|
|
const autoPaginationAttemptCount = useRef(0)
|
|
useEffect(() => {
|
|
const {data, isLoading, isRefetching, isFetchingNextPage, hasNextPage} =
|
|
query
|
|
// Count the items that we already have.
|
|
let itemCount = 0
|
|
for (const page of data?.pages || []) {
|
|
itemCount += page.items.length
|
|
}
|
|
|
|
// If items got truncated, reset the state we're tracking below.
|
|
if (itemCount !== lastItemCount.current) {
|
|
if (itemCount < lastItemCount.current) {
|
|
wantedItemCount.current = itemCount
|
|
}
|
|
lastItemCount.current = itemCount
|
|
}
|
|
|
|
// Now track how many items we really want, and fetch more if needed.
|
|
if (isLoading || isRefetching) {
|
|
// During the initial fetch, we want to get an entire page's worth of items.
|
|
wantedItemCount.current = PAGE_SIZE
|
|
} else if (isFetchingNextPage) {
|
|
if (itemCount > wantedItemCount.current) {
|
|
// We have more items than wantedItemCount, so wantedItemCount must be out of date.
|
|
// Some other code must have called fetchNextPage(), for example, from onEndReached.
|
|
// Adjust the wantedItemCount to reflect that we want one more full page of items.
|
|
wantedItemCount.current = itemCount + PAGE_SIZE
|
|
}
|
|
} else if (hasNextPage) {
|
|
// At this point we're not fetching anymore, so it's time to make a decision.
|
|
// If we didn't receive enough items from the server, paginate again until we do.
|
|
if (itemCount < wantedItemCount.current) {
|
|
autoPaginationAttemptCount.current++
|
|
if (autoPaginationAttemptCount.current < 50 /* failsafe */) {
|
|
query.fetchNextPage()
|
|
}
|
|
} else {
|
|
autoPaginationAttemptCount.current = 0
|
|
}
|
|
}
|
|
}, [query])
|
|
|
|
return query
|
|
}
|
|
|
|
export function* findAllPostsInQueryData(
|
|
queryClient: QueryClient,
|
|
uri: string,
|
|
): Generator<AppBskyFeedDefs.PostView, void> {
|
|
const atUri = new AtUri(uri)
|
|
|
|
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
|
queryKey: [RQKEY_ROOT],
|
|
})
|
|
for (const [_queryKey, queryData] of queryDatas) {
|
|
if (!queryData?.pages) {
|
|
continue
|
|
}
|
|
|
|
for (const page of queryData?.pages) {
|
|
for (const item of page.items) {
|
|
if (item.type !== 'starterpack-joined') {
|
|
if (item.subject && didOrHandleUriMatches(atUri, item.subject)) {
|
|
yield item.subject
|
|
}
|
|
}
|
|
|
|
if (AppBskyFeedDefs.isPostView(item.subject)) {
|
|
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
|
if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) {
|
|
yield embedViewRecordToPostView(quotedPost!)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function* findAllProfilesInQueryData(
|
|
queryClient: QueryClient,
|
|
did: string,
|
|
): Generator<bsky.profile.AnyProfileView, void> {
|
|
const queryDatas = queryClient.getQueriesData<InfiniteData<FeedPage>>({
|
|
queryKey: [RQKEY_ROOT],
|
|
})
|
|
for (const [_queryKey, queryData] of queryDatas) {
|
|
if (!queryData?.pages) {
|
|
continue
|
|
}
|
|
for (const page of queryData?.pages) {
|
|
for (const item of page.items) {
|
|
if (
|
|
(item.type === 'follow' || item.type === 'contact-match') &&
|
|
item.notification.author.did === did
|
|
) {
|
|
yield item.notification.author
|
|
} else if (
|
|
item.type !== 'starterpack-joined' &&
|
|
item.subject?.author.did === did
|
|
) {
|
|
yield item.subject.author
|
|
}
|
|
if (AppBskyFeedDefs.isPostView(item.subject)) {
|
|
const quotedPost = getEmbeddedPost(item.subject?.embed)
|
|
if (quotedPost?.author.did === did) {
|
|
yield quotedPost.author
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|