diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts new file mode 100644 index 0000000000..a1cf59954f --- /dev/null +++ b/src/state/cache/profile-shadow.ts @@ -0,0 +1,88 @@ +import {useEffect, useState, useCallback, useRef} from 'react' +import EventEmitter from 'eventemitter3' +import {AppBskyActorDefs} from '@atproto/api' + +const emitter = new EventEmitter() + +export interface ProfileShadow { + followingUri: string | undefined + muted: boolean | undefined + blockingUri: string | undefined +} + +interface CacheEntry { + ts: number + value: ProfileShadow +} + +type ProfileView = + | AppBskyActorDefs.ProfileView + | AppBskyActorDefs.ProfileViewBasic + | AppBskyActorDefs.ProfileViewDetailed + +export function useProfileShadow( + profile: T, + ifAfterTS: number, +): T { + const [state, setState] = useState({ + ts: Date.now(), + value: fromProfile(profile), + }) + const firstRun = useRef(true) + + const onUpdate = useCallback( + (value: Partial) => { + setState(s => ({ts: Date.now(), value: {...s.value, ...value}})) + }, + [setState], + ) + + // react to shadow updates + useEffect(() => { + emitter.addListener(profile.did, onUpdate) + return () => { + emitter.removeListener(profile.did, onUpdate) + } + }, [profile.did, onUpdate]) + + // react to profile updates + useEffect(() => { + // dont fire on first run to avoid needless re-renders + if (!firstRun.current) { + setState({ts: Date.now(), value: fromProfile(profile)}) + } + firstRun.current = false + }, [profile]) + + return state.ts > ifAfterTS ? mergeShadow(profile, state.value) : profile +} + +export function updateProfileShadow( + uri: string, + value: Partial, +) { + emitter.emit(uri, value) +} + +function fromProfile(profile: ProfileView): ProfileShadow { + return { + followingUri: profile.viewer?.following, + muted: profile.viewer?.muted, + blockingUri: profile.viewer?.blocking, + } +} + +function mergeShadow( + profile: T, + shadow: ProfileShadow, +): T { + return { + ...profile, + viewer: { + ...(profile.viewer || {}), + following: shadow.followingUri, + muted: shadow.muted, + blocking: shadow.blockingUri, + }, + } +} diff --git a/src/state/queries/profile.ts b/src/state/queries/profile.ts index 10a17d6119..fad9a1fd73 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,6 +1,7 @@ import {AtUri} from '@atproto/api' -import {useQuery, useQueryClient, useMutation} from '@tanstack/react-query' +import {useQuery, useMutation} from '@tanstack/react-query' import {useSession} from '../session' +import {updateProfileShadow} from '../cache/profile-shadow' export function useProfileQuery({did}: {did: string | undefined}) { const {agent} = useSession() @@ -16,14 +17,26 @@ export function useProfileQuery({did}: {did: string | undefined}) { export function useProfileFollowMutation() { const {agent} = useSession() - const queryClient = useQueryClient() return useMutation<{uri: string; cid: string}, Error, {did: string}>({ mutationFn: async ({did}) => { return await agent.follow(did) }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + followingUri: 'pending', + }) + }, onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + // finalize + updateProfileShadow(variables.did, { + followingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: undefined, }) }, }) @@ -31,14 +44,20 @@ export function useProfileFollowMutation() { export function useProfileUnfollowMutation() { const {agent} = useSession() - const queryClient = useQueryClient() return useMutation({ mutationFn: async ({followUri}) => { return await agent.deleteFollow(followUri) }, - onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: variables.followUri, }) }, }) @@ -46,14 +65,20 @@ export function useProfileUnfollowMutation() { export function useProfileMuteMutation() { const {agent} = useSession() - const queryClient = useQueryClient() return useMutation({ mutationFn: async ({did}) => { await agent.mute(did) }, - onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: true, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: false, }) }, }) @@ -61,14 +86,20 @@ export function useProfileMuteMutation() { export function useProfileUnmuteMutation() { const {agent} = useSession() - const queryClient = useQueryClient() return useMutation({ mutationFn: async ({did}) => { await agent.unmute(did) }, - onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: false, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: true, }) }, }) @@ -76,7 +107,6 @@ export function useProfileUnmuteMutation() { export function useProfileBlockMutation() { const {agent, currentAccount} = useSession() - const queryClient = useQueryClient() return useMutation<{uri: string; cid: string}, Error, {did: string}>({ mutationFn: async ({did}) => { if (!currentAccount) { @@ -87,9 +117,22 @@ export function useProfileBlockMutation() { {subject: did, createdAt: new Date().toISOString()}, ) }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + blockingUri: 'pending', + }) + }, onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + // finalize + updateProfileShadow(variables.did, { + blockingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: undefined, }) }, }) @@ -97,7 +140,6 @@ export function useProfileBlockMutation() { export function useProfileUnblockMutation() { const {agent, currentAccount} = useSession() - const queryClient = useQueryClient() return useMutation({ mutationFn: async ({blockUri}) => { if (!currentAccount) { @@ -109,9 +151,16 @@ export function useProfileUnblockMutation() { rkey, }) }, - onSuccess(data, variables) { - queryClient.invalidateQueries({ - queryKey: ['profile', variables.did], + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: variables.blockUri, }) }, }) diff --git a/src/view/com/posts/FeedErrorMessage.tsx b/src/view/com/posts/FeedErrorMessage.tsx index feb4b1c99c..e29b35f8a9 100644 --- a/src/view/com/posts/FeedErrorMessage.tsx +++ b/src/view/com/posts/FeedErrorMessage.tsx @@ -13,6 +13,7 @@ import {logger} from '#/logger' import {useModalControls} from '#/state/modals' import {FeedDescriptor} from '#/state/queries/post-feed' import {EmptyState} from '../util/EmptyState' +import {cleanError} from '#/lib/strings/errors' enum KnownError { Block, @@ -69,7 +70,12 @@ export function FeedErrorMessage({ ) } - return + return ( + + ) } function FeedgenErrorMessage({ diff --git a/src/view/com/profile/ProfileHeader.tsx b/src/view/com/profile/ProfileHeader.tsx index 58ea210c06..eb8ca6cabb 100644 --- a/src/view/com/profile/ProfileHeader.tsx +++ b/src/view/com/profile/ProfileHeader.tsx @@ -54,7 +54,6 @@ import {sanitizeHandle} from 'lib/strings/handles' import {shareUrl} from 'lib/sharing' import {s, colors} from 'lib/styles' import {logger} from '#/logger' -import {UseMutationResult} from '@tanstack/react-query' import {useSession} from '#/state/session' interface Props { @@ -143,31 +142,6 @@ function ProfileHeaderLoaded({ const blockMutation = useProfileBlockMutation() const unblockMutation = useProfileUnblockMutation() - const optimistic = ( - setMutation: {isPending: boolean}, - unsetMutation: {isPending: boolean}, - v: boolean, - ) => { - if (setMutation.isPending) return true - if (unsetMutation.isPending) return false - return v - } - const isFollowing = optimistic( - followMutation, - unfollowMutation, - !!profile.viewer?.following, - ) - const isMuting = optimistic( - muteMutation, - unmuteMutation, - !!profile.viewer?.muted, - ) - const isBlocking = optimistic( - blockMutation, - unblockMutation, - !!profile.viewer?.blocking, - ) - const onPressBack = React.useCallback(() => { if (navigation.canGoBack()) { navigation.goBack() @@ -197,7 +171,7 @@ function ProfileHeaderLoaded({ profile.displayName || profile.handle, )}`, ) - } catch (e) { + } catch (e: any) { logger.error('Failed to follow', {error: String(e)}) Toast.show(`There was an issue! ${e.toString()}`) } @@ -218,7 +192,7 @@ function ProfileHeaderLoaded({ profile.displayName || profile.handle, )}`, ) - } catch (e) { + } catch (e: any) { logger.error('Failed to unfollow', {error: String(e)}) Toast.show(`There was an issue! ${e.toString()}`) } @@ -359,11 +333,13 @@ function ProfileHeaderLoaded({ }, }) if (!isMe) { - if (!isBlocking) { + if (!profile.viewer?.blocking) { items.push({ testID: 'profileHeaderDropdownMuteBtn', - label: isMuting ? 'Unmute Account' : 'Mute Account', - onPress: isMuting ? onPressUnmuteAccount : onPressMuteAccount, + label: profile.viewer?.muted ? 'Unmute Account' : 'Mute Account', + onPress: profile.viewer?.muted + ? onPressUnmuteAccount + : onPressMuteAccount, icon: { ios: { name: 'speaker.slash', @@ -376,8 +352,10 @@ function ProfileHeaderLoaded({ if (!profile.viewer?.blockingByList) { items.push({ testID: 'profileHeaderDropdownBlockBtn', - label: isBlocking ? 'Unblock Account' : 'Block Account', - onPress: isBlocking ? onPressUnblockAccount : onPressBlockAccount, + label: profile.viewer?.blocking ? 'Unblock Account' : 'Block Account', + onPress: profile.viewer?.blocking + ? onPressUnblockAccount + : onPressBlockAccount, icon: { ios: { name: 'person.fill.xmark', @@ -403,8 +381,8 @@ function ProfileHeaderLoaded({ return items }, [ isMe, - isMuting, - isBlocking, + profile.viewer?.muted, + profile.viewer?.blocking, profile.viewer?.blockingByList, onPressShare, onPressUnmuteAccount, @@ -415,7 +393,8 @@ function ProfileHeaderLoaded({ onPressAddRemoveLists, ]) - const blockHide = !isMe && (isBlocking || profile.viewer?.blockedBy) + const blockHide = + !isMe && (profile.viewer?.blocking || profile.viewer?.blockedBy) const following = formatCount(profile.followsCount || 0) const followers = formatCount(profile.followersCount || 0) const pluralizedFollowers = pluralize(profile.followersCount || 0, 'follower') @@ -437,7 +416,7 @@ function ProfileHeaderLoaded({ Edit Profile - ) : isBlocking ? ( + ) : profile.viewer?.blocking ? ( profile.viewer?.blockingByList ? null : ( )} - {isFollowing ? ( + {profile.viewer?.following ? ( @@ -86,14 +92,17 @@ export const ProfileScreen = withAuthRequired(function ProfileScreenImpl({ }) function ProfileScreenLoaded({ - profile, + profile: profileUnshadowed, + dataUpdatedAt, moderationOpts, hideBackButton, }: { profile: AppBskyActorDefs.ProfileViewDetailed + dataUpdatedAt: number moderationOpts: ModerationOpts hideBackButton: boolean }) { + const profile = useProfileShadow(profileUnshadowed, dataUpdatedAt) const store = useStores() const {currentAccount} = useSession() const setMinimalShellMode = useSetMinimalShellMode() @@ -251,7 +260,7 @@ const FeedSection = React.forwardRef( {feed, onScroll, headerHeight, isScrolledDown, scrollElRef}, ref, ) { - const hasNew = TODO //feed.hasNewLatest && !feed.isRefreshing + const hasNew = false //TODO feed.hasNewLatest && !feed.isRefreshing const onScrollToTop = React.useCallback(() => { scrollElRef.current?.scrollToOffset({offset: -headerHeight}) diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx index c9a03ce627..d7814cb5d5 100644 --- a/src/view/shell/desktop/LeftNav.tsx +++ b/src/view/shell/desktop/LeftNav.tsx @@ -251,7 +251,7 @@ function ComposeBtn() { } export const DesktopLeftNav = observer(function DesktopLeftNav() { - const store = useStores() + const {currentAccount} = useSession() const pal = usePalette('default') const {isDesktop, isTablet} = useWebMediaQueries() const numUnread = useUnreadNotifications() @@ -370,7 +370,7 @@ export const DesktopLeftNav = observer(function DesktopLeftNav() { label="Moderation" />