diff --git a/assets/icons/pin_filled_stroke2_corner0_rounded.svg b/assets/icons/pin_filled_stroke2_corner0_rounded.svg new file mode 100644 index 0000000000..a2e71b967c --- /dev/null +++ b/assets/icons/pin_filled_stroke2_corner0_rounded.svg @@ -0,0 +1 @@ + diff --git a/package.json b/package.json index bbe46aa7c3..23e2bb324a 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index cf00215264..7443128d2c 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -55,7 +55,6 @@ import {NotificationsScreen} from '#/view/screens/Notifications' import {PostThreadScreen} from '#/view/screens/PostThread' import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' import {ProfileScreen} from '#/view/screens/Profile' -import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' import {ProfileListScreen} from '#/view/screens/ProfileList' import {SavedFeeds} from '#/view/screens/SavedFeeds' @@ -75,6 +74,7 @@ import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' import {PostQuotesScreen} from '#/screens/Post/PostQuotes' import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' +import {ProfileFeedScreen} from '#/screens/Profile/ProfileFeed' import {ProfileFollowersScreen} from '#/screens/Profile/ProfileFollowers' import {ProfileFollowsScreen} from '#/screens/Profile/ProfileFollows' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' diff --git a/src/alf/themes.ts b/src/alf/themes.ts index 0cfe09aadc..cb97a7065b 100644 --- a/src/alf/themes.ts +++ b/src/alf/themes.ts @@ -60,6 +60,7 @@ export function createThemes({ dim: Theme } { const color = { + like: '#ec4899', trueBlack: '#000000', gray_0: `hsl(${hues.primary}, 20%, ${defaultScale[14]}%)`, @@ -124,6 +125,7 @@ export function createThemes({ const lightPalette = { white: color.gray_0, black: color.gray_1000, + like: color.like, contrast_25: color.gray_25, contrast_50: color.gray_50, @@ -185,6 +187,7 @@ export function createThemes({ const darkPalette: Palette = { white: color.gray_25, black: color.trueBlack, + like: color.like, contrast_25: color.gray_975, contrast_50: color.gray_950, @@ -246,6 +249,7 @@ export function createThemes({ const dimPalette: Palette = { ...darkPalette, black: `hsl(${hues.primary}, 28%, ${dimScale[0]}%)`, + like: color.like, contrast_25: `hsl(${hues.primary}, 28%, ${dimScale[1]}%)`, contrast_50: `hsl(${hues.primary}, 28%, ${dimScale[2]}%)`, diff --git a/src/alf/types.ts b/src/alf/types.ts index 08ec593927..5bac690e2b 100644 --- a/src/alf/types.ts +++ b/src/alf/types.ts @@ -12,6 +12,7 @@ export type ThemeName = 'light' | 'dim' | 'dark' export type Palette = { white: string black: string + like: string contrast_25: string contrast_50: string diff --git a/src/components/Layout/index.tsx b/src/components/Layout/index.tsx index d08505fbfd..8532cbbb49 100644 --- a/src/components/Layout/index.tsx +++ b/src/components/Layout/index.tsx @@ -21,6 +21,7 @@ export * as Header from '#/components/Layout/Header' export type ScreenProps = React.ComponentProps & { style?: StyleProp + noInsetTop?: boolean } /** @@ -28,6 +29,7 @@ export type ScreenProps = React.ComponentProps & { */ export const Screen = React.memo(function Screen({ style, + noInsetTop, ...props }: ScreenProps) { const {top} = useSafeAreaInsets() @@ -35,7 +37,7 @@ export const Screen = React.memo(function Screen({ <> {isWeb && } diff --git a/src/components/icons/Pin.tsx b/src/components/icons/Pin.tsx index 03dbbac90e..d1c37f39a3 100644 --- a/src/components/icons/Pin.tsx +++ b/src/components/icons/Pin.tsx @@ -3,3 +3,7 @@ import {createSinglePathSVG} from './TEMPLATE' export const Pin_Stroke2_Corner0_Rounded = createSinglePathSVG({ path: 'M6.5 3a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v3.997a6.25 6.25 0 0 0 1.83 4.42l.377.376A1 1 0 0 1 20 12.5V15a1 1 0 0 1-1 1h-6v5a1 1 0 1 1-2 0v-5H5a1 1 0 0 1-1-1v-2.5a1 1 0 0 1 .293-.707l.376-.377A6.25 6.25 0 0 0 6.5 6.996V3.001Zm2 1v2.997a8.25 8.25 0 0 1-2.416 5.834L6 12.914V14h12v-1.086l-.084-.083A8.25 8.25 0 0 1 15.5 6.997V4h-7Z', }) + +export const Pin_Filled_Corner0_Rounded = createSinglePathSVG({ + path: 'M7.5 2a1 1 0 0 0-1 1v3.997a6.25 6.25 0 0 1-1.83 4.42l-.377.376A1 1 0 0 0 4 12.5V15a1 1 0 0 0 1 1h6v5a1 1 0 1 0 2 0v-5h6a1 1 0 0 0 1-1v-2.5a1 1 0 0 0-.293-.707l-.376-.377a6.25 6.25 0 0 1-1.831-4.42V3.001a1 1 0 0 0-1-1h-9Z', +}) diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts index 69ae536d02..2ec3fcb799 100644 --- a/src/lib/hooks/useNotificationHandler.ts +++ b/src/lib/hooks/useNotificationHandler.ts @@ -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() } }) diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 9e3407261d..238e4be4c3 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -75,7 +75,7 @@ export type SearchTabNavigatorParams = CommonNavigatorParams & { } export type NotificationsTabNavigatorParams = CommonNavigatorParams & { - Notifications: {show?: 'all'} + Notifications: undefined } export type MyProfileTabNavigatorParams = CommonNavigatorParams & { @@ -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'} } @@ -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 diff --git a/src/screens/Hashtag.tsx b/src/screens/Hashtag.tsx index a87487150c..3e98f364bf 100644 --- a/src/screens/Hashtag.tsx +++ b/src/screens/Hashtag.tsx @@ -107,7 +107,7 @@ export default function HashtagScreen({ return ( - + {headerTitle} diff --git a/src/screens/Profile/ProfileFeed/index.tsx b/src/screens/Profile/ProfileFeed/index.tsx new file mode 100644 index 0000000000..7d48b5ac15 --- /dev/null +++ b/src/screens/Profile/ProfileFeed/index.tsx @@ -0,0 +1,227 @@ +import React, {useCallback, useMemo} from 'react' +import {StyleSheet, View} from 'react-native' +import {useAnimatedRef} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useIsFocused, useNavigation} from '@react-navigation/native' +import {NativeStackScreenProps} from '@react-navigation/native-stack' +import {useQueryClient} from '@tanstack/react-query' + +import {usePalette} from '#/lib/hooks/usePalette' +import {useSetTitle} from '#/lib/hooks/useSetTitle' +import {ComposeIcon2} from '#/lib/icons' +import {CommonNavigatorParams} from '#/lib/routes/types' +import {NavigationProp} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {s} from '#/lib/styles' +import {isNative} from '#/platform/detection' +import {listenSoftReset} from '#/state/events' +import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' +import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' +import {FeedDescriptor} from '#/state/queries/post-feed' +import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' +import { + usePreferencesQuery, + UsePreferencesQueryResponse, +} from '#/state/queries/preferences' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {truncateAndInvalidate} from '#/state/queries/util' +import {useSession} from '#/state/session' +import {useComposerControls} from '#/state/shell/composer' +import {PostFeed} from '#/view/com/posts/PostFeed' +import {EmptyState} from '#/view/com/util/EmptyState' +import {FAB} from '#/view/com/util/fab/FAB' +import {Button} from '#/view/com/util/forms/Button' +import {ListRef} from '#/view/com/util/List' +import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' +import {LoadingScreen} from '#/view/com/util/LoadingScreen' +import {Text} from '#/view/com/util/text/Text' +import {ProfileFeedHeader} from '#/screens/Profile/components/ProfileFeedHeader' +import * as Layout from '#/components/Layout' + +type Props = NativeStackScreenProps +export function ProfileFeedScreen(props: Props) { + const {rkey, name: handleOrDid} = props.route.params + + const pal = usePalette('default') + const {_} = useLingui() + const navigation = useNavigation() + + const uri = useMemo( + () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), + [rkey, handleOrDid], + ) + const {error, data: resolvedUri} = useResolveUriQuery(uri) + + const onPressBack = React.useCallback(() => { + if (navigation.canGoBack()) { + navigation.goBack() + } else { + navigation.navigate('Home') + } + }, [navigation]) + + if (error) { + return ( + + + + + Could not load feed + + + {error.toString()} + + + + + + + + + ) + } + + return resolvedUri ? ( + + + + ) : ( + + + + ) +} + +function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { + const {data: preferences} = usePreferencesQuery() + const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) + + if (!preferences || !info) { + return + } + + return ( + + ) +} + +export function ProfileFeedScreenInner({ + feedInfo, +}: { + preferences: UsePreferencesQueryResponse + feedInfo: FeedSourceFeedInfo +}) { + const {_} = useLingui() + const {hasSession} = useSession() + const {openComposer} = useComposerControls() + const isScreenFocused = useIsFocused() + + useSetTitle(feedInfo?.displayName) + + const feed = `feedgen|${feedInfo.uri}` as FeedDescriptor + + const [hasNew, setHasNew] = React.useState(false) + const [isScrolledDown, setIsScrolledDown] = React.useState(false) + const queryClient = useQueryClient() + const feedFeedback = useFeedFeedback(feed, hasSession) + const scrollElRef = useAnimatedRef() as ListRef + + const onScrollToTop = useCallback(() => { + scrollElRef.current?.scrollToOffset({ + animated: isNative, + offset: 0, // -headerHeight, + }) + truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) + setHasNew(false) + }, [scrollElRef, queryClient, feed, setHasNew]) + + React.useEffect(() => { + if (!isScreenFocused) { + return + } + return listenSoftReset(onScrollToTop) + }, [onScrollToTop, isScreenFocused]) + + const renderPostsEmpty = useCallback(() => { + return + }, [_]) + + return ( + <> + + + + + + + {(isScrolledDown || hasNew) && ( + + )} + + {hasSession && ( + openComposer({})} + icon={ + + } + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + )} + + ) +} + +const styles = StyleSheet.create({ + btn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 7, + paddingHorizontal: 14, + borderRadius: 50, + marginLeft: 6, + }, + notFoundContainer: { + margin: 10, + paddingHorizontal: 18, + paddingVertical: 14, + borderRadius: 6, + }, + aboutSectionContainer: { + paddingVertical: 4, + paddingHorizontal: 16, + gap: 12, + }, +}) diff --git a/src/screens/Profile/components/ProfileFeedHeader.tsx b/src/screens/Profile/components/ProfileFeedHeader.tsx new file mode 100644 index 0000000000..afaa849819 --- /dev/null +++ b/src/screens/Profile/components/ProfileFeedHeader.tsx @@ -0,0 +1,539 @@ +import React from 'react' +import {View} from 'react-native' +import {useSafeAreaInsets} from 'react-native-safe-area-context' +import {AtUri} from '@atproto/api' +import {msg, Plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useHaptics} from '#/lib/haptics' +import {makeProfileLink} from '#/lib/routes/links' +import {makeCustomFeedLink} from '#/lib/routes/links' +import {shareUrl} from '#/lib/sharing' +import {sanitizeHandle} from '#/lib/strings/handles' +import {toShareUrl} from '#/lib/strings/url-helpers' +import {logger} from '#/logger' +import {FeedSourceFeedInfo} from '#/state/queries/feed' +import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' +import { + useAddSavedFeedsMutation, + usePreferencesQuery, + useRemoveFeedMutation, + useUpdateSavedFeedsMutation, +} from '#/state/queries/preferences' +import {useSession} from '#/state/session' +import {formatCount} from '#/view/com/util/numeric/format' +import * as Toast from '#/view/com/util/Toast' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' +import {Button, ButtonIcon, ButtonText} from '#/components/Button' +import * as Dialog from '#/components/Dialog' +import {Divider} from '#/components/Divider' +import {useRichText} from '#/components/hooks/useRichText' +import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' +import {ChevronBottom_Stroke2_Corner0_Rounded as ChevronDown} from '#/components/icons/Chevron' +import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' +import { + Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, + Heart2_Stroke2_Corner0_Rounded as Heart, +} from '#/components/icons/Heart2' +import { + Pin_Filled_Corner0_Rounded as PinFilled, + Pin_Stroke2_Corner0_Rounded as Pin, +} from '#/components/icons/Pin' +import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' +import * as Layout from '#/components/Layout' +import {InlineLinkText} from '#/components/Link' +import {Loader} from '#/components/Loader' +import * as Menu from '#/components/Menu' +import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' +import {RichText} from '#/components/RichText' +import {Text} from '#/components/Typography' + +export function ProfileFeedHeader({info}: {info: FeedSourceFeedInfo}) { + const t = useTheme() + const {_, i18n} = useLingui() + const {hasSession} = useSession() + const {gtPhone, gtMobile} = useBreakpoints() + const {top} = useSafeAreaInsets() + const infoControl = Dialog.useDialogControl() + const playHaptic = useHaptics() + + const {data: preferences} = usePreferencesQuery() + + const [likeUri, setLikeUri] = React.useState(info.likeUri || '') + const isLiked = !!likeUri + const likeCount = + isLiked && likeUri ? (info.likeCount || 0) + 1 : info.likeCount || 0 + + const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = + useAddSavedFeedsMutation() + const {mutateAsync: removeFeed, isPending: isRemovePending} = + useRemoveFeedMutation() + const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = + useUpdateSavedFeedsMutation() + + const isFeedStateChangePending = + isAddSavedFeedPending || isRemovePending || isUpdateFeedPending + const savedFeedConfig = preferences?.savedFeeds?.find( + f => f.value === info.uri, + ) + const isSaved = Boolean(savedFeedConfig) + const isPinned = Boolean(savedFeedConfig?.pinned) + + const onToggleSaved = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + await removeFeed(savedFeedConfig) + Toast.show(_(msg`Removed from your feeds`)) + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: false, + }, + ]) + Toast.show(_(msg`Saved to your feeds`)) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue updating your feeds, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to update feeds', {message: err}) + } + }, [_, playHaptic, info, removeFeed, addSavedFeeds, savedFeedConfig]) + + const onTogglePinned = React.useCallback(async () => { + try { + playHaptic() + + if (savedFeedConfig) { + const pinned = !savedFeedConfig.pinned + await updateSavedFeeds([ + { + ...savedFeedConfig, + pinned, + }, + ]) + + if (pinned) { + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } else { + Toast.show(_(msg`Un-pinned ${info.displayName} from Home`)) + } + } else { + await addSavedFeeds([ + { + type: 'feed', + value: info.uri, + pinned: true, + }, + ]) + Toast.show(_(msg`Pinned ${info.displayName} to Home`)) + } + } catch (e) { + Toast.show(_(msg`There was an issue contacting the server`), 'xmark') + logger.error('Failed to toggle pinned feed', {message: e}) + } + }, [playHaptic, info, _, savedFeedConfig, updateSavedFeeds, addSavedFeeds]) + + return ( + <> + + + + + + + + {hasSession && ( + + {isPinned ? ( + + + {({props}) => { + return ( + + ) + }} + + + + + {_(msg`Unpin from home`)} + + + + + {isSaved + ? _(msg`Remove from my feeds`) + : _(msg`Save to my feeds`)} + + + + + + ) : ( + + )} + + )} + + + + + + + + + + + ) +} + +function DialogInner({ + info, + likeUri, + setLikeUri, + likeCount, + isPinned, + onTogglePinned, + isFeedStateChangePending, +}: { + info: FeedSourceFeedInfo + likeUri: string + setLikeUri: (uri: string) => void + likeCount: number + isPinned: boolean + onTogglePinned: () => void + isFeedStateChangePending: boolean +}) { + const t = useTheme() + const {_} = useLingui() + const {hasSession} = useSession() + const playHaptic = useHaptics() + const control = Dialog.useDialogContext() + const reportDialogControl = useReportDialogControl() + const [rt, loading] = useRichText(info.description.text) + const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() + const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = + useUnlikeMutation() + + const isLiked = !!likeUri + const feedRkey = React.useMemo(() => new AtUri(info.uri).rkey, [info.uri]) + + const onToggleLiked = React.useCallback(async () => { + try { + playHaptic() + + if (isLiked && likeUri) { + await unlikeFeed({uri: likeUri}) + setLikeUri('') + } else { + const res = await likeFeed({uri: info.uri, cid: info.cid}) + setLikeUri(res.uri) + } + } catch (err) { + Toast.show( + _( + msg`There was an issue contacting the server, please check your internet connection and try again.`, + ), + 'xmark', + ) + logger.error('Failed to toggle like', {message: err}) + } + }, [playHaptic, isLiked, likeUri, unlikeFeed, setLikeUri, likeFeed, info, _]) + + const onPressShare = React.useCallback(() => { + playHaptic() + const url = toShareUrl(info.route.href) + shareUrl(url) + }, [info, playHaptic]) + + const onPressReport = React.useCallback(() => { + reportDialogControl.open() + }, [reportDialogControl]) + + return loading ? ( + + ) : ( + + + + + + + {info.displayName} + + + By{' '} + control.close()}> + {sanitizeHandle(info.creatorHandle, '@')} + + + + + + + + + + + {typeof likeCount === 'number' && ( + control.close()}> + + Liked by + + + )} + + + {hasSession && ( + <> + + + + + + + + + + + Something wrong? Let us know. + + + + + + + + + )} + + ) +} diff --git a/src/screens/Settings/NotificationSettings.tsx b/src/screens/Settings/NotificationSettings.tsx index 1c77b31489..ebb230c2ca 100644 --- a/src/screens/Settings/NotificationSettings.tsx +++ b/src/screens/Settings/NotificationSettings.tsx @@ -18,7 +18,13 @@ type Props = NativeStackScreenProps 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 { diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts index 19a92fc3c0..72100a6245 100644 --- a/src/state/queries/notifications/feed.ts +++ b/src/state/queries/notifications/feed.ts @@ -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, @@ -91,14 +88,23 @@ 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, @@ -106,13 +112,13 @@ export function useNotificationFeedQuery(opts?: { 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() } diff --git a/src/state/queries/notifications/settings.ts b/src/state/queries/notifications/settings.ts index a17fce8326..e552b65202 100644 --- a/src/state/queries/notifications/settings.ts +++ b/src/state/queries/notifications/settings.ts @@ -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')}) }, }) } @@ -54,7 +55,7 @@ function eagerlySetCachedPriority( queryClient: ReturnType, enabled: boolean, ) { - queryClient.setQueryData(RQKEY_NOTIFS(), (old: any) => { + function updateData(old: any) { if (!old) return old return { ...old, @@ -65,5 +66,7 @@ function eagerlySetCachedPriority( } }), } - }) + } + queryClient.setQueryData(RQKEY_NOTIFS('all'), updateData) + queryClient.setQueryData(RQKEY_NOTIFS('mentions'), updateData) } diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx index 2ade04246f..ba2377a784 100644 --- a/src/state/queries/notifications/unread.tsx +++ b/src/state/queries/notifications/unread.tsx @@ -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' @@ -105,6 +105,8 @@ export function Provider({children}: React.PropsWithChildren<{}>) { } }, [setNumUnread]) + const isFetchingRef = useRef(false) + // create API const api = React.useMemo(() => { return { @@ -138,6 +140,12 @@ 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, @@ -145,6 +153,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) { limit: 40, queryClient, moderationOpts, + reasons: [], // only fetch subjects when the page is going to be used // in the notifications query, otherwise skip it @@ -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 } }, diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts index a251d170ec..0d72e9e929 100644 --- a/src/state/queries/notifications/util.ts +++ b/src/state/queries/notifications/util.ts @@ -31,6 +31,7 @@ export async function fetchPage({ queryClient, moderationOpts, fetchAdditionalData, + reasons, }: { agent: BskyAgent cursor: string | undefined @@ -38,7 +39,7 @@ export async function fetchPage({ queryClient: QueryClient moderationOpts: ModerationOpts | undefined fetchAdditionalData: boolean - priority?: boolean + reasons: string[] }): Promise<{ page: FeedPage indexedAt: string | undefined @@ -46,7 +47,7 @@ export async function fetchPage({ const res = await agent.listNotifications({ limit, cursor, - // priority, + reasons, }) const indexedAt = res.data.notifications[0]?.indexedAt diff --git a/src/state/queries/util.ts b/src/state/queries/util.ts index 0d6a8e99ac..887c1df0ac 100644 --- a/src/state/queries/util.ts +++ b/src/state/queries/util.ts @@ -8,7 +8,7 @@ import { } from '@atproto/api' import {InfiniteData, QueryClient, QueryKey} from '@tanstack/react-query' -export function truncateAndInvalidate( +export async function truncateAndInvalidate( queryClient: QueryClient, queryKey: QueryKey, ) { @@ -21,7 +21,7 @@ export function truncateAndInvalidate( } return data }) - queryClient.invalidateQueries({queryKey}) + return queryClient.invalidateQueries({queryKey}) } // Given an AtUri, this function will check if the AtUri matches a diff --git a/src/view/com/notifications/NotificationFeed.tsx b/src/view/com/notifications/NotificationFeed.tsx index 5168933aeb..0b814e68dc 100644 --- a/src/view/com/notifications/NotificationFeed.tsx +++ b/src/view/com/notifications/NotificationFeed.tsx @@ -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' @@ -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 }) { const initialNumToRender = useInitialNumToRender() - const [isPTRing, setIsPTRing] = React.useState(false) - const pal = usePalette('default') - const {_} = useLingui() const moderationOpts = useModerationOpts() - const {checkUnread} = useUnreadNotificationsApi() const { data, isFetching, @@ -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 @@ -85,7 +83,7 @@ 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, @@ -93,7 +91,7 @@ export function NotificationFeed({ } finally { setIsPTRing(false) } - }, [checkUnread, setIsPTRing]) + }, [refreshNotifications, setIsPTRing]) const onEndReached = React.useCallback(async () => { if (isFetching || !hasNextPage || isError) return @@ -129,21 +127,18 @@ export function NotificationFeed({ /> ) } else if (item === LOADING_ITEM) { - return ( - - - - ) + return } return ( ) }, - [moderationOpts, _, onPressRetryLoadMore, pal.border], + [moderationOpts, _, onPressRetryLoadMore, filter], ) const FeedFooter = React.useCallback( diff --git a/src/view/com/notifications/NotificationFeedItem.tsx b/src/view/com/notifications/NotificationFeedItem.tsx index 4902e66bc7..1267ce0894 100644 --- a/src/view/com/notifications/NotificationFeedItem.tsx +++ b/src/view/com/notifications/NotificationFeedItem.tsx @@ -79,10 +79,12 @@ interface Author { let NotificationFeedItem = ({ item, moderationOpts, + highlightUnread, hideTopBorder, }: { item: FeedNotification moderationOpts: ModerationOpts + highlightUnread: boolean hideTopBorder?: boolean }): React.ReactNode => { const queryClient = useQueryClient() @@ -151,6 +153,7 @@ let NotificationFeedItem = ({ if (!item.subject) { return null } + const isHighlighted = highlightUnread && !item.notification.isRead return ( diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx index 2c0bbee520..b3f936ddc4 100644 --- a/src/view/com/pager/Pager.tsx +++ b/src/view/com/pager/Pager.tsx @@ -136,12 +136,14 @@ export const Pager = forwardRef>( return ( - {renderTabBar({ - selectedPage, - onSelect: onTabBarSelect, - dragProgress, - dragState, - })} + + {renderTabBar({ + selectedPage, + onSelect: onTabBarSelect, + dragProgress, + dragState, + })} + ([]) + const textLayouts = useSharedValue<{width: number}[]>([]) const itemsLength = items.length const scrollToOffsetJS = useCallback( @@ -211,21 +212,40 @@ export function TabBar({ [layouts], ) + const onTextLayout = useCallback( + (i: number, layout: {width: number}) => { + 'worklet' + textLayouts.modify(ls => { + ls[i] = layout + return ls + }) + }, + [textLayouts], + ) + const indicatorStyle = useAnimatedStyle(() => { if (!_WORKLET) { return {opacity: 0} } const layoutsValue = layouts.get() + const textLayoutsValue = textLayouts.get() if ( layoutsValue.length !== itemsLength || - layoutsValue.some(l => l === undefined) + textLayoutsValue.length !== itemsLength ) { return { opacity: 0, } } - if (layoutsValue.length === 1) { - return {opacity: 1} + if (textLayoutsValue.length === 1) { + return { + opacity: 1, + transform: [ + { + scaleX: textLayoutsValue[0].width / contentSize.get(), + }, + ], + } } return { opacity: 1, @@ -240,10 +260,8 @@ export function TabBar({ { scaleX: interpolate( dragProgress.get(), - layoutsValue.map((l, i) => i), - layoutsValue.map( - l => (l.width - ITEM_PADDING * 2) / contentSize.get(), - ), + textLayoutsValue.map((l, i) => i), + textLayoutsValue.map(l => l.width / contentSize.get()), ), }, ], @@ -287,7 +305,7 @@ export function TabBar({ onLayout={e => { contentSize.set(e.nativeEvent.layout.width) }} - style={{flexDirection: 'row'}}> + style={{flexDirection: 'row', flexGrow: 1}}> {items.map((item, i) => { return ( ) })} @@ -328,6 +347,7 @@ function TabBarItem({ item, onPressItem, onItemLayout, + onTextLayout, }: { index: number testID: string | undefined @@ -335,6 +355,7 @@ function TabBarItem({ item: string onPressItem: (index: number) => void onItemLayout: (index: number, layout: {x: number; width: number}) => void + onTextLayout: (index: number, layout: {width: number}) => void }) { const t = useTheme() const style = useAnimatedStyle(() => { @@ -358,8 +379,15 @@ function TabBarItem({ [index, onItemLayout], ) + const handleTextLayout = useCallback( + (e: LayoutChangeEvent) => { + runOnUI(onTextLayout)(index, e.nativeEvent.layout) + }, + [index, onTextLayout], + ) + return ( - + + style={[styles.itemText, t.atoms.text, a.text_md, a.font_bold]} + onLayout={handleTextLayout}> {item} @@ -381,19 +410,28 @@ function TabBarItem({ const styles = StyleSheet.create({ contentContainer: { + flexGrow: 1, backgroundColor: 'transparent', paddingHorizontal: CONTENT_PADDING, }, item: { + flexGrow: 1, paddingTop: 10, paddingHorizontal: ITEM_PADDING, justifyContent: 'center', }, itemInner: { + alignItems: 'center', + flexGrow: 1, paddingBottom: 10, borderBottomWidth: 3, borderBottomColor: 'transparent', }, + itemText: { + lineHeight: 20, + minWidth: 45, + textAlign: 'center', + }, outerBottomBorder: { position: 'absolute', left: 0, diff --git a/src/view/com/pager/TabBar.web.tsx b/src/view/com/pager/TabBar.web.tsx index 789f88e753..f44e03368e 100644 --- a/src/view/com/pager/TabBar.web.tsx +++ b/src/view/com/pager/TabBar.web.tsx @@ -115,12 +115,14 @@ export function TabBar({ hoverStyle={t.atoms.bg_contrast_25} onPress={() => onPressItem(i)} accessibilityRole="tab"> - + ) } - return + return ( + + ) } function MockAccountCard({ diff --git a/src/view/screens/Notifications.tsx b/src/view/screens/Notifications.tsx index 70ab32db0d..82c68dde64 100644 --- a/src/view/screens/Notifications.tsx +++ b/src/view/screens/Notifications.tsx @@ -13,7 +13,7 @@ import { } from '#/lib/routes/types' import {s} from '#/lib/styles' import {logger} from '#/logger' -import {isNative, isWeb} from '#/platform/detection' +import {isNative} from '#/platform/detection' import {emitSoftReset, listenSoftReset} from '#/state/events' import {RQKEY as NOTIFS_RQKEY} from '#/state/queries/notifications/feed' import { @@ -24,35 +24,173 @@ import {truncateAndInvalidate} from '#/state/queries/util' import {useSetMinimalShellMode} from '#/state/shell' import {useComposerControls} from '#/state/shell/composer' import {NotificationFeed} from '#/view/com/notifications/NotificationFeed' +import {Pager} from '#/view/com/pager/Pager' +import {TabBar} from '#/view/com/pager/TabBar' import {FAB} from '#/view/com/util/fab/FAB' import {ListMethods} from '#/view/com/util/List' import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' import {MainScrollProvider} from '#/view/com/util/MainScrollProvider' -import {atoms as a, useBreakpoints, useTheme} from '#/alf' -import {Button, ButtonIcon} from '#/components/Button' +import {atoms as a} from '#/alf' +import {web} from '#/alf' +import {ButtonIcon} from '#/components/Button' import {SettingsGear2_Stroke2_Corner0_Rounded as SettingsIcon} from '#/components/icons/SettingsGear2' import * as Layout from '#/components/Layout' import {Link} from '#/components/Link' import {Loader} from '#/components/Loader' +// We don't currently persist this across reloads since +// you gotta visit All to clear the badge anyway. +// But let's at least persist it during the sesssion. +let lastActiveTab = 0 + type Props = NativeStackScreenProps< NotificationsTabNavigatorParams, 'Notifications' > -export function NotificationsScreen({route: {params}}: Props) { - const t = useTheme() - const {gtTablet} = useBreakpoints() +export function NotificationsScreen({}: Props) { + const {_} = useLingui() + const {openComposer} = useComposerControls() + const unreadNotifs = useUnreadNotifications() + const hasNew = !!unreadNotifs + const {checkUnread: checkUnreadAll} = useUnreadNotificationsApi() + const [isLoadingAll, setIsLoadingAll] = React.useState(false) + const [isLoadingMentions, setIsLoadingMentions] = React.useState(false) + const initialActiveTab = lastActiveTab + const [activeTab, setActiveTab] = React.useState(initialActiveTab) + const isLoading = activeTab === 0 ? isLoadingAll : isLoadingMentions + + const onPageSelected = React.useCallback( + (index: number) => { + setActiveTab(index) + lastActiveTab = index + }, + [setActiveTab], + ) + + const queryClient = useQueryClient() + const checkUnreadMentions = React.useCallback( + async ({invalidate}: {invalidate: boolean}) => { + if (invalidate) { + return truncateAndInvalidate(queryClient, NOTIFS_RQKEY('mentions')) + } else { + // Background polling is not implemented for the mentions tab. + // Just ignore it. + } + }, + [queryClient], + ) + + const sections = React.useMemo(() => { + return [ + { + title: _(msg`All`), + component: ( + + ), + }, + { + title: _(msg`Mentions`), + component: ( + + ), + }, + ] + }, [ + _, + hasNew, + checkUnreadAll, + checkUnreadMentions, + activeTab, + isLoadingAll, + isLoadingMentions, + ]) + + return ( + + + + + + Notifications + + + + + + + + + ( + + section.title)} + onPressSelected={() => emitSoftReset()} + /> + + )} + initialPage={initialActiveTab}> + {sections.map((section, i) => ( + {section.component} + ))} + + openComposer({})} + icon={} + accessibilityRole="button" + accessibilityLabel={_(msg`New post`)} + accessibilityHint="" + /> + + ) +} + +function NotificationsTab({ + filter, + isActive, + isLoading, + hasNew, + checkUnread, + setIsLoadingLatest, +}: { + filter: 'all' | 'mentions' + isActive: boolean + isLoading: boolean + hasNew: boolean + checkUnread: ({invalidate}: {invalidate: boolean}) => Promise + setIsLoadingLatest: (v: boolean) => void +}) { const {_} = useLingui() const setMinimalShellMode = useSetMinimalShellMode() const [isScrolledDown, setIsScrolledDown] = React.useState(false) - const [isLoadingLatest, setIsLoadingLatest] = React.useState(false) const scrollElRef = React.useRef(null) const queryClient = useQueryClient() - const unreadNotifs = useUnreadNotifications() - const unreadApi = useUnreadNotificationsApi() - const hasNew = !!unreadNotifs const isScreenFocused = useIsFocused() - const {openComposer} = useComposerControls() + const isFocusedAndActive = isScreenFocused && isActive // event handlers // = @@ -65,16 +203,23 @@ export function NotificationsScreen({route: {params}}: Props) { scrollToTop() if (hasNew) { // render what we have now - truncateAndInvalidate(queryClient, NOTIFS_RQKEY()) - } else { + truncateAndInvalidate(queryClient, NOTIFS_RQKEY(filter)) + } else if (!isLoading) { // check with the server setIsLoadingLatest(true) - unreadApi - .checkUnread({invalidate: true}) + checkUnread({invalidate: true}) .catch(() => undefined) .then(() => setIsLoadingLatest(false)) } - }, [scrollToTop, queryClient, unreadApi, hasNew, setIsLoadingLatest]) + }, [ + scrollToTop, + queryClient, + checkUnread, + hasNew, + isLoading, + setIsLoadingLatest, + filter, + ]) const onFocusCheckLatest = useNonReactiveCallback(() => { // on focus, check for latest, but only invalidate if the user @@ -87,79 +232,36 @@ export function NotificationsScreen({route: {params}}: Props) { // we're just going to look it up synchronously. currentIsScrolledDown = window.scrollY > 200 } - unreadApi.checkUnread({invalidate: !currentIsScrolledDown}) + checkUnread({invalidate: !currentIsScrolledDown}) }) // on-visible setup // = useFocusEffect( React.useCallback(() => { - setMinimalShellMode(false) - logger.debug('NotificationsScreen: Focus') - onFocusCheckLatest() - }, [setMinimalShellMode, onFocusCheckLatest]), + if (isFocusedAndActive) { + setMinimalShellMode(false) + logger.debug('NotificationsScreen: Focus') + onFocusCheckLatest() + } + }, [setMinimalShellMode, onFocusCheckLatest, isFocusedAndActive]), ) React.useEffect(() => { - if (!isScreenFocused) { + if (!isFocusedAndActive) { return } return listenSoftReset(onPressLoadLatest) - }, [onPressLoadLatest, isScreenFocused]) + }, [onPressLoadLatest, isFocusedAndActive]) return ( - - - - - - - - - - - - - + <> checkUnread({invalidate: true})} onScrolledDownChange={setIsScrolledDown} scrollElRef={scrollElRef} - overridePriorityNotifications={params?.show === 'all'} /> {(isScrolledDown || hasNew) && ( @@ -169,14 +271,6 @@ export function NotificationsScreen({route: {params}}: Props) { showIndicator={hasNew} /> )} - openComposer({})} - icon={} - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - + ) } diff --git a/src/view/screens/ProfileFeed.tsx b/src/view/screens/ProfileFeed.tsx deleted file mode 100644 index c3f98c067f..0000000000 --- a/src/view/screens/ProfileFeed.tsx +++ /dev/null @@ -1,621 +0,0 @@ -import React, {useCallback, useMemo} from 'react' -import {Pressable, StyleSheet, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg, Plural, Trans} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useIsFocused, useNavigation} from '@react-navigation/native' -import {NativeStackScreenProps} from '@react-navigation/native-stack' -import {useQueryClient} from '@tanstack/react-query' - -import {HITSLOP_20} from '#/lib/constants' -import {useHaptics} from '#/lib/haptics' -import {usePalette} from '#/lib/hooks/usePalette' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {ComposeIcon2} from '#/lib/icons' -import {makeCustomFeedLink} from '#/lib/routes/links' -import {CommonNavigatorParams} from '#/lib/routes/types' -import {NavigationProp} from '#/lib/routes/types' -import {shareUrl} from '#/lib/sharing' -import {makeRecordUri} from '#/lib/strings/url-helpers' -import {toShareUrl} from '#/lib/strings/url-helpers' -import {s} from '#/lib/styles' -import {logger} from '#/logger' -import {isNative} from '#/platform/detection' -import {listenSoftReset} from '#/state/events' -import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback' -import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed' -import {useLikeMutation, useUnlikeMutation} from '#/state/queries/like' -import {FeedDescriptor} from '#/state/queries/post-feed' -import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed' -import { - useAddSavedFeedsMutation, - usePreferencesQuery, - UsePreferencesQueryResponse, - useRemoveFeedMutation, - useUpdateSavedFeedsMutation, -} from '#/state/queries/preferences' -import {useResolveUriQuery} from '#/state/queries/resolve-uri' -import {truncateAndInvalidate} from '#/state/queries/util' -import {useSession} from '#/state/session' -import {useComposerControls} from '#/state/shell/composer' -import {PagerWithHeader} from '#/view/com/pager/PagerWithHeader' -import {PostFeed} from '#/view/com/posts/PostFeed' -import {ProfileSubpageHeader} from '#/view/com/profile/ProfileSubpageHeader' -import {EmptyState} from '#/view/com/util/EmptyState' -import {FAB} from '#/view/com/util/fab/FAB' -import {Button} from '#/view/com/util/forms/Button' -import {ListRef} from '#/view/com/util/List' -import {LoadLatestBtn} from '#/view/com/util/load-latest/LoadLatestBtn' -import {LoadingScreen} from '#/view/com/util/LoadingScreen' -import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' -import {atoms as a, useTheme} from '#/alf' -import {Button as NewButton, ButtonText} from '#/components/Button' -import {useRichText} from '#/components/hooks/useRichText' -import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox' -import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' -import { - Heart2_Filled_Stroke2_Corner0_Rounded as HeartFilled, - Heart2_Stroke2_Corner0_Rounded as HeartOutline, -} from '#/components/icons/Heart2' -import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' -import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash' -import * as Layout from '#/components/Layout' -import {InlineLinkText} from '#/components/Link' -import * as Menu from '#/components/Menu' -import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog' -import {RichText} from '#/components/RichText' - -const SECTION_TITLES = ['Posts'] - -interface SectionRef { - scrollToTop: () => void -} - -type Props = NativeStackScreenProps -export function ProfileFeedScreen(props: Props) { - const {rkey, name: handleOrDid} = props.route.params - - const pal = usePalette('default') - const {_} = useLingui() - const navigation = useNavigation() - - const uri = useMemo( - () => makeRecordUri(handleOrDid, 'app.bsky.feed.generator', rkey), - [rkey, handleOrDid], - ) - const {error, data: resolvedUri} = useResolveUriQuery(uri) - - const onPressBack = React.useCallback(() => { - if (navigation.canGoBack()) { - navigation.goBack() - } else { - navigation.navigate('Home') - } - }, [navigation]) - - if (error) { - return ( - - - - - Could not load feed - - - {error.toString()} - - - - - - - - - ) - } - - return resolvedUri ? ( - - - - ) : ( - - - - ) -} - -function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) { - const {data: preferences} = usePreferencesQuery() - const {data: info} = useFeedSourceInfoQuery({uri: feedUri}) - - if (!preferences || !info) { - return - } - - return ( - - ) -} - -export function ProfileFeedScreenInner({ - preferences, - feedInfo, -}: { - preferences: UsePreferencesQueryResponse - feedInfo: FeedSourceFeedInfo -}) { - const {_} = useLingui() - const t = useTheme() - const {hasSession, currentAccount} = useSession() - const reportDialogControl = useReportDialogControl() - const {openComposer} = useComposerControls() - const playHaptic = useHaptics() - const feedSectionRef = React.useRef(null) - const isScreenFocused = useIsFocused() - - const {mutateAsync: addSavedFeeds, isPending: isAddSavedFeedPending} = - useAddSavedFeedsMutation() - const {mutateAsync: removeFeed, isPending: isRemovePending} = - useRemoveFeedMutation() - const {mutateAsync: updateSavedFeeds, isPending: isUpdateFeedPending} = - useUpdateSavedFeedsMutation() - - const isPending = - isAddSavedFeedPending || isRemovePending || isUpdateFeedPending - const savedFeedConfig = preferences.savedFeeds.find( - f => f.value === feedInfo.uri, - ) - const isSaved = Boolean(savedFeedConfig) - const isPinned = Boolean(savedFeedConfig?.pinned) - - useSetTitle(feedInfo?.displayName) - - // event handlers - // - - const onToggleSaved = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await removeFeed(savedFeedConfig) - Toast.show(_(msg`Removed from your feeds`)) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: false, - }, - ]) - Toast.show(_(msg`Saved to your feeds`)) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue updating your feeds, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to update feeds', {message: err}) - } - }, [_, playHaptic, feedInfo, removeFeed, addSavedFeeds, savedFeedConfig]) - - const onTogglePinned = React.useCallback(async () => { - try { - playHaptic() - - if (savedFeedConfig) { - await updateSavedFeeds([ - { - ...savedFeedConfig, - pinned: !savedFeedConfig.pinned, - }, - ]) - } else { - await addSavedFeeds([ - { - type: 'feed', - value: feedInfo.uri, - pinned: true, - }, - ]) - } - } catch (e) { - Toast.show(_(msg`There was an issue contacting the server`), 'xmark') - logger.error('Failed to toggle pinned feed', {message: e}) - } - }, [ - playHaptic, - feedInfo, - _, - savedFeedConfig, - updateSavedFeeds, - addSavedFeeds, - ]) - - const onPressShare = React.useCallback(() => { - const url = toShareUrl(feedInfo.route.href) - shareUrl(url) - }, [feedInfo]) - - const onPressReport = React.useCallback(() => { - reportDialogControl.open() - }, [reportDialogControl]) - - const onCurrentPageSelected = React.useCallback( - (index: number) => { - if (index === 0) { - feedSectionRef.current?.scrollToTop() - } - }, - [feedSectionRef], - ) - - const renderHeader = useCallback(() => { - return ( - <> - - - {feedInfo && hasSession && ( - - - {isPinned ? _(msg`Unpin`) : _(msg`Pin to Home`)} - - - )} - - - {({props, state}) => { - return ( - - - - ) - }} - - - - - {hasSession && ( - <> - - - {isSaved - ? _(msg`Remove from my feeds`) - : _(msg`Save to my feeds`)} - - - - - - {_(msg`Report feed`)} - - - - )} - - - {_(msg`Share feed`)} - - - - - - - - - - ) - }, [ - _, - hasSession, - feedInfo, - isPinned, - onTogglePinned, - onToggleSaved, - currentAccount?.did, - isSaved, - onPressReport, - onPressShare, - t, - isPending, - ]) - - return ( - <> - - - {({headerHeight, scrollElRef, isFocused}) => ( - - )} - - {hasSession && ( - openComposer({})} - icon={ - - } - accessibilityRole="button" - accessibilityLabel={_(msg`New post`)} - accessibilityHint="" - /> - )} - - ) -} - -interface FeedSectionProps { - feed: FeedDescriptor - headerHeight: number - scrollElRef: ListRef - isFocused: boolean -} -const FeedSection = React.forwardRef( - function FeedSectionImpl({feed, headerHeight, scrollElRef, isFocused}, ref) { - const {_} = useLingui() - const [hasNew, setHasNew] = React.useState(false) - const [isScrolledDown, setIsScrolledDown] = React.useState(false) - const queryClient = useQueryClient() - const isScreenFocused = useIsFocused() - const {hasSession} = useSession() - const feedFeedback = useFeedFeedback(feed, hasSession) - - const onScrollToTop = useCallback(() => { - scrollElRef.current?.scrollToOffset({ - animated: isNative, - offset: -headerHeight, - }) - truncateAndInvalidate(queryClient, FEED_RQKEY(feed)) - setHasNew(false) - }, [scrollElRef, headerHeight, queryClient, feed, setHasNew]) - - React.useImperativeHandle(ref, () => ({ - scrollToTop: onScrollToTop, - })) - - React.useEffect(() => { - if (!isScreenFocused) { - return - } - return listenSoftReset(onScrollToTop) - }, [onScrollToTop, isScreenFocused]) - - const renderPostsEmpty = useCallback(() => { - return - }, [_]) - - return ( - - - - - {(isScrolledDown || hasNew) && ( - - )} - - ) - }, -) - -function AboutSection({ - feedOwnerDid, - feedRkey, - feedInfo, -}: { - feedOwnerDid: string - feedRkey: string - feedInfo: FeedSourceFeedInfo -}) { - const t = useTheme() - const pal = usePalette('default') - const {_} = useLingui() - const [likeUri, setLikeUri] = React.useState(feedInfo.likeUri) - const {hasSession} = useSession() - const playHaptic = useHaptics() - const {mutateAsync: likeFeed, isPending: isLikePending} = useLikeMutation() - const {mutateAsync: unlikeFeed, isPending: isUnlikePending} = - useUnlikeMutation() - const [resolvedRT] = useRichText(feedInfo.description.text || '') - - const isLiked = !!likeUri - const likeCount = - isLiked && likeUri ? (feedInfo.likeCount || 0) + 1 : feedInfo.likeCount - - const onToggleLiked = React.useCallback(async () => { - try { - playHaptic() - - if (isLiked && likeUri) { - await unlikeFeed({uri: likeUri}) - setLikeUri('') - } else { - const res = await likeFeed({uri: feedInfo.uri, cid: feedInfo.cid}) - setLikeUri(res.uri) - } - } catch (err) { - Toast.show( - _( - msg`There was an issue contacting the server, please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to toggle like', {message: err}) - } - }, [playHaptic, isLiked, likeUri, unlikeFeed, likeFeed, feedInfo, _]) - - return ( - - - {feedInfo.description ? ( - - ) : ( - - No description - - )} - - - - - {isLiked ? ( - - ) : ( - - )} - - {typeof likeCount === 'number' && ( - - - Liked by - - - )} - - - ) -} - -const styles = StyleSheet.create({ - btn: { - flexDirection: 'row', - alignItems: 'center', - gap: 6, - paddingVertical: 7, - paddingHorizontal: 14, - borderRadius: 50, - marginLeft: 6, - }, - notFoundContainer: { - margin: 10, - paddingHorizontal: 18, - paddingVertical: 14, - borderRadius: 6, - }, - aboutSectionContainer: { - paddingVertical: 4, - paddingHorizontal: 16, - gap: 12, - }, -}) diff --git a/yarn.lock b/yarn.lock index 16fe9579a4..9e2259e0d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20,21 +20,21 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" -"@atproto-labs/fetch-node@0.1.3": - version "0.1.3" - resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.3.tgz#2581bf4710a4f957c74c75d959961de3304b3595" - integrity sha512-KX3ogPJt6dXNppWImQ9omfhrc8t73WrJaxHMphRAqQL8jXxKW5NBCTjSuwroBkJ1pj1aValBrc5NpdYu+H/9Qg== +"@atproto-labs/fetch-node@0.1.4": + version "0.1.4" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch-node/-/fetch-node-0.1.4.tgz#03859a39556eab936e2b3bec2d087585c6408cb3" + integrity sha512-hwYx0XpgIl2zydRy13DtWvywruuHk1EX+yCjqjgUIezUm8fi35ZN4QvR6INEm0MpN2MD/kQsImPbd8ZftzZ3zw== dependencies: - "@atproto-labs/fetch" "0.1.1" + "@atproto-labs/fetch" "0.1.2" "@atproto-labs/pipe" "0.1.0" ipaddr.js "^2.1.0" psl "^1.9.0" undici "^6.14.1" -"@atproto-labs/fetch@0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.1.tgz#10e7f8c06cf01a63f58e130b95d9ee0d4171902c" - integrity sha512-X1zO1MDoJzEurbWXMAe1H8EZ995Xam/aXdxhGVrXmOMyPDuvBa1oxwh/kQNZRCKcMQUbiwkk+Jfq6ZkTuvGbww== +"@atproto-labs/fetch@0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@atproto-labs/fetch/-/fetch-0.1.2.tgz#e1b9354205fb76f106ae3e1c6b56e7865a39600f" + integrity sha512-7mQQIRtVenqtdBQKCqoLjyAhPS2aA56EGEjyz5zB3sramM3qkrvzyusr55GAzGDS0tvB6cy9cDEtSLmfK7LUnA== dependencies: "@atproto-labs/pipe" "0.1.0" optionalDependencies: @@ -58,28 +58,28 @@ resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== -"@atproto/api@^0.13.18": - version "0.13.18" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.18.tgz#cc537cc3b4c8d03f258a373f4d893fea11a77cdd" - integrity sha512-rrl5HhzGYWZ7fiC965TPBUOVItq9M4dxMb6qz8IvAVQliSkrJrKc7UD0QWL89QiiXaOBuX8w+4i5r4wrfBGddg== +"@atproto/api@^0.13.20": + version "0.13.20" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.20.tgz#5140db303c3b0981958dfe6a5fa6d7d1cd7bb3cc" + integrity sha512-z/+CvG6BEttRHf856tKSe1AeUQNfrobRJldaHAthGmFk7O3wLZQyfcI9DUmBJQ9+4wAt0dZwvKWVGLZOV9eLHA== dependencies: "@atproto/common-web" "^0.3.1" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" + "@atproto/xrpc" "^0.6.5" await-lock "^2.2.2" multiformats "^9.9.0" tlds "^1.234.0" zod "^3.23.8" -"@atproto/aws@^0.2.9": - version "0.2.9" - resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.9.tgz#3539b281b725914b769451ee4afc62315dff1afc" - integrity sha512-sc9aXUePcqItkJSOJJnGNVthVfAKjhn3zMDG+RRLzKUBye6Yutrlhpt1yxNZLHQiqIK5fy2Cuc4EX3p3jeWUYw== +"@atproto/aws@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.10.tgz#e0b888fd50308cc24b7086cf3ec209587c13bbe4" + integrity sha512-zQElKk6wGTQo5aKdXtmx/dINjkVgbJU9+C/xOVTs+M88I8IrrBxPvo1dASLJcMtRb9VjXh5snLJeAjgyx6qC6Q== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" - "@atproto/repo" "^0.5.5" + "@atproto/repo" "^0.6.0" "@aws-sdk/client-cloudfront" "^3.261.0" "@aws-sdk/client-kms" "^3.196.0" "@aws-sdk/client-s3" "^3.224.0" @@ -89,20 +89,20 @@ multiformats "^9.9.0" uint8arrays "3.0.0" -"@atproto/bsky@^0.0.96": - version "0.0.96" - resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.96.tgz#b89abf2828f57738357beb4efd05539667dd14b3" - integrity sha512-Tk0ppiPMKdcnPU3x+uBAVRn92vroznhr2OlqinNSy/PZ39qWViRlKAhG3CLJsU2gjSHxsNfaIwulj7tPvKCmSw== +"@atproto/bsky@^0.0.98": + version "0.0.98" + resolved "https://registry.yarnpkg.com/@atproto/bsky/-/bsky-0.0.98.tgz#4c4746e588568df1878647ae80cf4b963bc95924" + integrity sha512-Y+un2pD1W1H0s0IWdY6S4vLy8rgR8cpqThz9onn4wDppmGWvOBNXeD8AjNzIWC0iFlYcfR4rwCKSoccUXYzxNg== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/common" "^0.4.4" + "@atproto/api" "^0.13.20" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/repo" "^0.5.5" - "@atproto/sync" "^0.1.6" + "@atproto/lexicon" "^0.4.4" + "@atproto/repo" "^0.6.0" + "@atproto/sync" "^0.1.7" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" "@connectrpc/connect-express" "^1.1.4" @@ -129,12 +129,12 @@ typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/bsync@^0.0.9": - version "0.0.9" - resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.9.tgz#7a6d58ef776404893d3c1139bdfe606fef483612" - integrity sha512-N0+TnYOoJz4hTo6/h1jJKh6QzdbwkFuOQ1bdwugzST7ZkwMtjs5FX8o/uqgiD4gSHSqfQSRrew7+qYEHUT61Aw== +"@atproto/bsync@^0.0.10": + version "0.0.10" + resolved "https://registry.yarnpkg.com/@atproto/bsync/-/bsync-0.0.10.tgz#fa16acfaf67112449b703778a20c785226c94189" + integrity sha512-qviPMyYade/sqhX/9X9eTT4KaQ+FLvOyz+140LCDk/0vbZUCZPuYSEXZDCQkL5nlEXzScsQ3iyVeoYCGvV5kYw== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/syntax" "^0.3.1" "@bufbuild/protobuf" "^1.5.0" "@connectrpc/connect" "^1.1.4" @@ -175,10 +175,10 @@ pino "^8.6.1" zod "^3.14.2" -"@atproto/common@^0.4.4": - version "0.4.4" - resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.4.tgz#79096aef920f5ad7cda5c682d7ed7416d0581e1a" - integrity sha512-58tMbn6A1Zu296s/l3uIj8z9d7IRHpZvLOfsFRikaQaYrzhJpL2aPY4uFQ8GJcxnsxeUnxBCrQz9we5jVVJI5Q== +"@atproto/common@^0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@atproto/common/-/common-0.4.5.tgz#28fd176a9b5527c723828e725586bc0be9fa9516" + integrity sha512-LFAGqHcxCI5+b31Xgk+VQQtZU258iGPpHJzNeHVcdh6teIKZi4C2l6YV+m+3CEz+yYcfP7jjUmgqesx7l9Arsg== dependencies: "@atproto/common-web" "^0.3.1" "@ipld/dag-cbor" "^7.0.3" @@ -207,23 +207,23 @@ "@noble/hashes" "^1.3.1" uint8arrays "3.0.0" -"@atproto/dev-env@^0.3.64": - version "0.3.64" - resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.64.tgz#148537785b6a86b0a56d0988e63a1ff8ea7c84e9" - integrity sha512-s7mdppgp2BS0uy5ASZwqJ3J8dez14pDGI9uqTGbsOYF/qTCbBGZKw/Vkqjci5bY1UaW+o6n787q63ECDtljM8A== +"@atproto/dev-env@^0.3.67": + version "0.3.67" + resolved "https://registry.yarnpkg.com/@atproto/dev-env/-/dev-env-0.3.67.tgz#4f6a20f0aafa8125ed9ec715abceedd11580882e" + integrity sha512-7Ize4Y5vdjQjyrxTwjBPbkxKXQdE02KpE7AJLJt6Xpvowd2vbn8l8rDXfha+LtVi6t/613U4s+Slo5c1YD3x9A== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/bsky" "^0.0.96" - "@atproto/bsync" "^0.0.9" + "@atproto/api" "^0.13.20" + "@atproto/bsky" "^0.0.98" + "@atproto/bsync" "^0.0.10" "@atproto/common-web" "^0.3.1" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/ozone" "^0.1.57" - "@atproto/pds" "^0.4.73" - "@atproto/sync" "^0.1.6" + "@atproto/lexicon" "^0.4.4" + "@atproto/ozone" "^0.1.59" + "@atproto/pds" "^0.4.76" + "@atproto/sync" "^0.1.7" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.1" "@did-plc/server" "^0.0.1" axios "^0.27.2" @@ -258,10 +258,10 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/lexicon@^0.4.3": - version "0.4.3" - resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.3.tgz#d69f6bb363a6326df7766c48132bfa30e22622d9" - integrity sha512-lFVZXe1S1pJP0dcxvJuHP3r/a+EAIBwwU7jUK+r8iLhIja+ml6NmYv8KeFHmIJATh03spEQ9s02duDmFVdCoXg== +"@atproto/lexicon@^0.4.4": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@atproto/lexicon/-/lexicon-0.4.4.tgz#0d97314bb57b693b76f2495fa5e02872469dd93a" + integrity sha512-QFEmr3rpj/RoAmfX9ALU/asBG/rsVtQZnw+9nOB1/AuIwoxXd+ZyndR6lVUc2+DL4GEjl6W2yvBru5xbQIZWyA== dependencies: "@atproto/common-web" "^0.3.1" "@atproto/syntax" "^0.3.1" @@ -269,20 +269,20 @@ multiformats "^9.9.0" zod "^3.23.8" -"@atproto/oauth-provider@^0.2.7": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.7.tgz#38a211c197ee1ce4e92a5b59a92f2e15fcacee0b" - integrity sha512-T/cEr7TGs36SqTW8JzLAt9EchumYY48zuI4rqoAepYT29eGpP37SxK+5X0+fQHOKJPKWUGlYocR9fDm4CdzAPQ== +"@atproto/oauth-provider@^0.2.10": + version "0.2.10" + resolved "https://registry.yarnpkg.com/@atproto/oauth-provider/-/oauth-provider-0.2.10.tgz#f9820d7f82c33d3b74e81a75873f50e1e654b901" + integrity sha512-cF42lo0+Mj+Zq2RXwS2NxmobmtL7YL1vXlYcN6iKflZ8pQ5WvpR/cZKsKEZOT9cEBBTw5MARKTYxbr8CPDKlHg== dependencies: - "@atproto-labs/fetch" "0.1.1" - "@atproto-labs/fetch-node" "0.1.3" + "@atproto-labs/fetch" "0.1.2" + "@atproto-labs/fetch-node" "0.1.4" "@atproto-labs/pipe" "0.1.0" "@atproto-labs/simple-store" "0.1.1" "@atproto-labs/simple-store-memory" "0.1.1" - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/jwk" "0.1.1" "@atproto/jwk-jose" "0.1.2" - "@atproto/oauth-types" "0.2.0" + "@atproto/oauth-types" "0.2.1" "@hapi/accept" "^6.0.3" "@hapi/bourne" "^3.0.0" "@hapi/content" "^6.0.0" @@ -294,27 +294,27 @@ psl "^1.9.0" zod "^3.23.8" -"@atproto/oauth-types@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.0.tgz#28bc861b56cba093e6c52603cec1d3d38cd2a1e7" - integrity sha512-v/4ht6eRh0yOu2iuuWujZdnJBamPKimdy8k0Xan8cVZ+a2i83UkhIIU+S/XUbbvJ4a64wLPZrS9IDd0K5XYYTQ== +"@atproto/oauth-types@0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@atproto/oauth-types/-/oauth-types-0.2.1.tgz#a7ace557cc91817fcde6195f023e4e1838e4aef6" + integrity sha512-hDisUXzcq5KU1HMuCYZ8Kcz7BePl7V11bFjjgZvND3mdSphiyBpJ8MCNn3QzAa6cXpFo0w9PDcYMAlCCRZHdVw== dependencies: "@atproto/jwk" "0.1.1" zod "^3.23.8" -"@atproto/ozone@^0.1.57": - version "0.1.57" - resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.57.tgz#141d66b213710575c7859d691586fd44c731f7ca" - integrity sha512-P2YKeRFPbxKc2e2yftUoMTTcWYuFV0qU1/Nkd4GxuHnBnDJcbtMPglXd7kyLf0p8plCCFau/wZ8QdY8KSDLM9Q== +"@atproto/ozone@^0.1.59": + version "0.1.59" + resolved "https://registry.yarnpkg.com/@atproto/ozone/-/ozone-0.1.59.tgz#219984a46617b0ac039f2f02767290eaa0b4cfc3" + integrity sha512-AD03Ocb3fZW+grxO/VwMld5iNdCLgbahFzku6xh1qEw0tLOBKp3GXSfepVd9XWu5fb1yPhGPd2JgjApV5hbJvw== dependencies: - "@atproto/api" "^0.13.18" - "@atproto/common" "^0.4.4" + "@atproto/api" "^0.13.20" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc" "^0.6.5" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.1" axios "^1.6.7" compression "^1.7.4" @@ -331,29 +331,30 @@ typed-emitter "^2.1.0" uint8arrays "3.0.0" -"@atproto/pds@^0.4.73": - version "0.4.73" - resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.73.tgz#49b7625d9b40a5a24be1cdd7cdb56faab9e25707" - integrity sha512-fzrKlgKVF5JvTTmhfvofXT9Ok1KFTfAjCzTrLJivbOcqQSqBagNTuz5CiQxAAAo/JTlSxmnyr3e7OrlJdrph1w== +"@atproto/pds@^0.4.76": + version "0.4.76" + resolved "https://registry.yarnpkg.com/@atproto/pds/-/pds-0.4.76.tgz#cd7b3f13359a7c31dc9362a5e4309419512c4102" + integrity sha512-+cFVpqlgpCS0BuGac5fCQPZUugpS1r7ghnSQLVdjnTnvQJCqLRA++BlJWYbGgRP6FJrumCY2jtuwG8t59Rjt8Q== dependencies: - "@atproto-labs/fetch-node" "0.1.3" - "@atproto/api" "^0.13.18" - "@atproto/aws" "^0.2.9" - "@atproto/common" "^0.4.4" + "@atproto-labs/fetch-node" "0.1.4" + "@atproto/api" "^0.13.20" + "@atproto/aws" "^0.2.10" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/oauth-provider" "^0.2.7" - "@atproto/repo" "^0.5.5" + "@atproto/lexicon" "^0.4.4" + "@atproto/oauth-provider" "^0.2.10" + "@atproto/repo" "^0.6.0" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc" "^0.6.4" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc" "^0.6.5" + "@atproto/xrpc-server" "^0.7.4" "@did-plc/lib" "^0.0.4" + "@hapi/address" "^5.1.1" better-sqlite3 "^10.0.0" bytes "^3.1.2" compression "^1.7.4" cors "^2.8.5" - disposable-email "^0.2.3" + disposable-email-domains-js "^1.5.0" express "^4.17.2" express-async-errors "^3.1.1" file-type "^16.5.4" @@ -376,49 +377,50 @@ undici "^6.19.8" zod "^3.23.8" -"@atproto/repo@^0.5.5": - version "0.5.5" - resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.5.5.tgz#73eaf1a0b35cfc4fc1c837f4e3ddeb6768d29c20" - integrity sha512-Zu1tw42KBVyFzIh1XYSIvm8V+V9oEKWJR7NnHBgeSMwCc9QwM32jO7uqgvEjZYEXgdYKanGhv/YHLyxtZa5Ckg== +"@atproto/repo@^0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@atproto/repo/-/repo-0.6.0.tgz#29e698731e6df63636b0f7c91ce106a9de50ad19" + integrity sha512-6YGVhjiHKmqCW5Ce4oY49E3NCEfbvAGowJ5ETXX2sx2l4D2bOL7a2hn5zWqsPHYpWSLjrPfnj7PVpApK0kmL7A== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/common-web" "^0.3.1" "@atproto/crypto" "^0.4.2" - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" "@ipld/car" "^3.2.3" "@ipld/dag-cbor" "^7.0.0" multiformats "^9.9.0" uint8arrays "3.0.0" zod "^3.23.8" -"@atproto/sync@^0.1.6": - version "0.1.6" - resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.6.tgz#fb3e61147c05caf2c3d1cd597ff94fef68abbc02" - integrity sha512-9lqe6E6fIns28TJyQufLCVefMxmK3bvEfQBhmXJBGZMHuKlH8+F5P9DfnHv6vs6ygfmHIUIjYDWqJu/rpt8pzw== +"@atproto/sync@^0.1.7": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@atproto/sync/-/sync-0.1.7.tgz#c7f78d99bb40eacf93ca13fdd04134a0985bf421" + integrity sha512-liJH2EsD4AbWA8G0oRDURgbHW6Uq4NnM2rNfbrTlqgtj0kyGRY3FcVEyqeRcaQYfCuscChIg5DQKHqY421/7Mw== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/identity" "^0.4.3" - "@atproto/lexicon" "^0.4.3" - "@atproto/repo" "^0.5.5" + "@atproto/lexicon" "^0.4.4" + "@atproto/repo" "^0.6.0" "@atproto/syntax" "^0.3.1" - "@atproto/xrpc-server" "^0.7.3" + "@atproto/xrpc-server" "^0.7.4" multiformats "^9.9.0" p-queue "^6.6.2" + ws "^8.12.0" "@atproto/syntax@^0.3.1": version "0.3.1" resolved "https://registry.yarnpkg.com/@atproto/syntax/-/syntax-0.3.1.tgz#4346418728f9643d783d2ffcf7c77e132e1f53d4" integrity sha512-fzW0Mg1QUOVCWUD3RgEsDt6d1OZ6DdFmbKcDdbzUfh0t4rhtRAC05KbZYmxuMPWDAiJ4BbbQ5dkAc/mNypMXkw== -"@atproto/xrpc-server@^0.7.3": - version "0.7.3" - resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.3.tgz#d09b36d00edb7aacca48675d1ebb7fa796fa11bd" - integrity sha512-x0qegkN6snrbXJO3v9h2kuh9e90g6ZZkDXv3COiraGS3yRTzIm6i4bMvDSfCI50+0xCNtPKOkpn8taRoRgkyiw== +"@atproto/xrpc-server@^0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@atproto/xrpc-server/-/xrpc-server-0.7.4.tgz#dfac8f7276c1c971a35eaba627eb6372088441c3" + integrity sha512-MrAwxfJBQm/kCol3D8qc+vpQzBMzLqvtUbauSSfVVJ10PlGtxg4LlXqcjkAuhrjyrqp3dQH9LHuhDpgVQK+G3w== dependencies: - "@atproto/common" "^0.4.4" + "@atproto/common" "^0.4.5" "@atproto/crypto" "^0.4.2" - "@atproto/lexicon" "^0.4.3" - "@atproto/xrpc" "^0.6.4" + "@atproto/lexicon" "^0.4.4" + "@atproto/xrpc" "^0.6.5" cbor-x "^1.5.1" express "^4.17.2" http-errors "^2.0.0" @@ -428,12 +430,12 @@ ws "^8.12.0" zod "^3.23.8" -"@atproto/xrpc@^0.6.4": - version "0.6.4" - resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.4.tgz#4cf59774f7c72e5bc821bc5f1d57f0a6ae2014db" - integrity sha512-9ZAJ8nsXTqC4XFyS0E1Wlg7bAvonhXQNQ3Ocs1L1LIwFLXvsw/4fNpIHXxvXvqTCVeyHLbImOnE9UiO1c/qIYA== +"@atproto/xrpc@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@atproto/xrpc/-/xrpc-0.6.5.tgz#8b180fc5f6b8374fd00c41b9e4cd7b24ead48e6b" + integrity sha512-t6u8iPEVbWge5RhzKZDahSzNDYIAxUtop6Q/X/apAZY1rgreVU0/1sSvvRoRFH19d3UIKjYdLuwFqMi9w8nY3Q== dependencies: - "@atproto/lexicon" "^0.4.3" + "@atproto/lexicon" "^0.4.4" zod "^3.23.8" "@aws-crypto/crc32@3.0.0": @@ -4378,6 +4380,13 @@ "@hapi/boom" "^10.0.1" "@hapi/hoek" "^11.0.2" +"@hapi/address@^5.1.1": + version "5.1.1" + resolved "https://registry.yarnpkg.com/@hapi/address/-/address-5.1.1.tgz#e9925fc1b65f5cc3fbea821f2b980e4652e84cb6" + integrity sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA== + dependencies: + "@hapi/hoek" "^11.0.2" + "@hapi/boom@^10.0.0", "@hapi/boom@^10.0.1": version "10.0.1" resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" @@ -9593,10 +9602,10 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" -disposable-email@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/disposable-email/-/disposable-email-0.2.3.tgz#a21a49717f6034a8ff777dc8eae3b4d994a7b988" - integrity sha512-gkBQQ5Res431ZXqLlAafrXHizG7/1FWmi8U2RTtriD78Vc10HhBUvdJun3R4eSF0KRIQQJs+wHlxjkED/Hr1EQ== +disposable-email-domains-js@^1.5.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/disposable-email-domains-js/-/disposable-email-domains-js-1.7.0.tgz#2bf859bccf7a2eb697025577e18f0434409713ec" + integrity sha512-qcIJcnXjDvH8EEt0tyAesk1sZVGU5ZFtW6Wys2wKCAcbUf5nJYfwZfT7Z0PVA/LBMlqd/Xgk9dXN2Q3fx7NFAg== dns-equal@^1.0.0: version "1.0.0"