diff options
Diffstat (limited to 'src/state')
-rw-r--r-- | src/state/cache/profile-shadow.ts | 88 | ||||
-rw-r--r-- | src/state/queries/profile.ts | 166 | ||||
-rw-r--r-- | src/state/queries/resolve-uri.ts | 15 |
3 files changed, 259 insertions, 10 deletions
diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts new file mode 100644 index 000000000..a1cf59954 --- /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<T extends ProfileView>( + profile: T, + ifAfterTS: number, +): T { + const [state, setState] = useState<CacheEntry>({ + ts: Date.now(), + value: fromProfile(profile), + }) + const firstRun = useRef(true) + + const onUpdate = useCallback( + (value: Partial<ProfileShadow>) => { + 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<ProfileShadow>, +) { + emitter.emit(uri, value) +} + +function fromProfile(profile: ProfileView): ProfileShadow { + return { + followingUri: profile.viewer?.following, + muted: profile.viewer?.muted, + blockingUri: profile.viewer?.blocking, + } +} + +function mergeShadow<T extends ProfileView>( + 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 c2cd19482..1bd28d5b1 100644 --- a/src/state/queries/profile.ts +++ b/src/state/queries/profile.ts @@ -1,13 +1,169 @@ -import {useQuery} from '@tanstack/react-query' +import {AtUri} from '@atproto/api' +import {useQuery, useMutation} from '@tanstack/react-query' +import {useSession} from '../session' +import {updateProfileShadow} from '../cache/profile-shadow' -import {PUBLIC_BSKY_AGENT} from '#/state/queries' +export const RQKEY = (did: string) => ['profile', did] -export function useProfileQuery({did}: {did: string}) { +export function useProfileQuery({did}: {did: string | undefined}) { + const {agent} = useSession() return useQuery({ - queryKey: ['getProfile', did], + queryKey: RQKEY(did), queryFn: async () => { - const res = await PUBLIC_BSKY_AGENT.getProfile({actor: did}) + const res = await agent.getProfile({actor: did || ''}) return res.data }, + enabled: !!did, + }) +} + +export function useProfileFollowMutation() { + const {agent} = useSession() + 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) { + // finalize + updateProfileShadow(variables.did, { + followingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + }, + }) +} + +export function useProfileUnfollowMutation() { + const {agent} = useSession() + return useMutation<void, Error, {did: string; followUri: string}>({ + mutationFn: async ({followUri}) => { + return await agent.deleteFollow(followUri) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + followingUri: undefined, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + followingUri: variables.followUri, + }) + }, + }) +} + +export function useProfileMuteMutation() { + const {agent} = useSession() + return useMutation<void, Error, {did: string}>({ + mutationFn: async ({did}) => { + await agent.mute(did) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: true, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: false, + }) + }, + }) +} + +export function useProfileUnmuteMutation() { + const {agent} = useSession() + return useMutation<void, Error, {did: string}>({ + mutationFn: async ({did}) => { + await agent.unmute(did) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + muted: false, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + muted: true, + }) + }, + }) +} + +export function useProfileBlockMutation() { + const {agent, currentAccount} = useSession() + return useMutation<{uri: string; cid: string}, Error, {did: string}>({ + mutationFn: async ({did}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + return await agent.app.bsky.graph.block.create( + {repo: currentAccount.did}, + {subject: did, createdAt: new Date().toISOString()}, + ) + }, + onMutate(variables) { + // optimstically update + updateProfileShadow(variables.did, { + blockingUri: 'pending', + }) + }, + onSuccess(data, variables) { + // finalize + updateProfileShadow(variables.did, { + blockingUri: data.uri, + }) + }, + onError(error, variables) { + // revert the optimistic update + updateProfileShadow(variables.did, { + blockingUri: undefined, + }) + }, + }) +} + +export function useProfileUnblockMutation() { + const {agent, currentAccount} = useSession() + return useMutation<void, Error, {did: string; blockUri: string}>({ + mutationFn: async ({blockUri}) => { + if (!currentAccount) { + throw new Error('Not signed in') + } + const {rkey} = new AtUri(blockUri) + await agent.app.bsky.graph.block.delete({ + repo: currentAccount.did, + rkey, + }) + }, + 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/state/queries/resolve-uri.ts b/src/state/queries/resolve-uri.ts index 26e0a475b..83bccdce7 100644 --- a/src/state/queries/resolve-uri.ts +++ b/src/state/queries/resolve-uri.ts @@ -4,17 +4,22 @@ import {useSession} from '../session' export const RQKEY = (uri: string) => ['resolved-uri', uri] -export function useResolveUriQuery(uri: string) { +export function useResolveUriQuery(uri: string | undefined) { const {agent} = useSession() - return useQuery<string | undefined, Error>({ - queryKey: RQKEY(uri), + return useQuery<{uri: string; did: string}, Error>({ + queryKey: RQKEY(uri || ''), async queryFn() { - const urip = new AtUri(uri) + const urip = new AtUri(uri || '') if (!urip.host.startsWith('did:')) { const res = await agent.resolveHandle({handle: urip.host}) urip.host = res.data.did } - return urip.toString() + return {did: urip.host, uri: urip.toString()} }, + enabled: !!uri, }) } + +export function useResolveDidQuery(didOrHandle: string | undefined) { + return useResolveUriQuery(didOrHandle ? `at://${didOrHandle}/` : undefined) +} |