about summary refs log tree commit diff
path: root/src/state
diff options
context:
space:
mode:
Diffstat (limited to 'src/state')
-rw-r--r--src/state/cache/profile-shadow.ts88
-rw-r--r--src/state/queries/profile.ts166
-rw-r--r--src/state/queries/resolve-uri.ts15
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)
+}