about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-06-18 19:48:34 +0100
committerGitHub <noreply@github.com>2024-06-18 21:48:34 +0300
commit5f5d845053e13169f89fc70a3f858b0a9e5ed4fd (patch)
tree2902f50fbfdce9b59b38d74c0dac96b3a2abf8b9
parent35e54e24a0b08ce0f2e3389aeb4fb0f29778170e (diff)
downloadvoidsky-5f5d845053e13169f89fc70a3f858b0a9e5ed4fd.tar.zst
Server-side thread mutes (#4518)
* update atproto/api

* move thread mutes to server side

* rm log

* move muted threads provider to inside did key

* use map instead of object
-rw-r--r--package.json2
-rw-r--r--src/App.native.tsx76
-rw-r--r--src/App.web.tsx72
-rw-r--r--src/lib/statsig/events.ts2
-rw-r--r--src/state/cache/thread-mutes.tsx44
-rw-r--r--src/state/muted-threads.tsx62
-rw-r--r--src/state/queries/notifications/feed.ts3
-rw-r--r--src/state/queries/notifications/unread.tsx5
-rw-r--r--src/state/queries/notifications/util.ts50
-rw-r--r--src/state/queries/post.ts70
-rw-r--r--src/view/com/util/forms/PostDropdownBtn.tsx45
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx4
-rw-r--r--yarn.lock8
13 files changed, 223 insertions, 220 deletions
diff --git a/package.json b/package.json
index 29e198c9c..417836903 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
   },
   "dependencies": {
-    "@atproto/api": "^0.12.18",
+    "@atproto/api": "^0.12.19",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
     "@braintree/sanitize-url": "^6.0.2",
     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 322e944a4..18461fdd0 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
 import {
   initialize,
   Provider as StatsigProvider,
   tryFetchGates,
 } from '#/lib/statsig/statsig'
+import {s} from '#/lib/styles'
+import {ThemeProvider} from '#/lib/ThemeContext'
 import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
 import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
 import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
 import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
 import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {s} from 'lib/styles'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
 import {
   Provider as SessionProvider,
   SessionAccount,
   useSession,
   useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import {TestCtrls} from 'view/com/testing/TestCtrls'
-import * as Toast from 'view/com/util/Toast'
-import {Shell} from 'view/shell'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import {TestCtrls} from '#/view/com/testing/TestCtrls'
+import * as Toast from '#/view/com/util/Toast'
+import {Shell} from '#/view/shell'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {Provider as PortalProvider} from '#/components/Portal'
@@ -112,10 +112,12 @@ function InnerApp() {
                             <SelectedFeedProvider>
                               <UnreadNotifsProvider>
                                 <BackgroundNotificationPreferencesProvider>
-                                  <GestureHandlerRootView style={s.h100pct}>
-                                    <TestCtrls />
-                                    <Shell />
-                                  </GestureHandlerRootView>
+                                  <MutedThreadsProvider>
+                                    <GestureHandlerRootView style={s.h100pct}>
+                                      <TestCtrls />
+                                      <Shell />
+                                    </GestureHandlerRootView>
+                                  </MutedThreadsProvider>
                                 </BackgroundNotificationPreferencesProvider>
                               </UnreadNotifsProvider>
                             </SelectedFeedProvider>
@@ -154,21 +156,19 @@ function App() {
       <SessionProvider>
         <ShellStateProvider>
           <PrefsStateProvider>
-            <MutedThreadsProvider>
-              <InvitesStateProvider>
-                <ModalStateProvider>
-                  <DialogStateProvider>
-                    <LightboxStateProvider>
-                      <I18nProvider>
-                        <PortalProvider>
-                          <InnerApp />
-                        </PortalProvider>
-                      </I18nProvider>
-                    </LightboxStateProvider>
-                  </DialogStateProvider>
-                </ModalStateProvider>
-              </InvitesStateProvider>
-            </MutedThreadsProvider>
+            <InvitesStateProvider>
+              <ModalStateProvider>
+                <DialogStateProvider>
+                  <LightboxStateProvider>
+                    <I18nProvider>
+                      <PortalProvider>
+                        <InnerApp />
+                      </PortalProvider>
+                    </I18nProvider>
+                  </LightboxStateProvider>
+                </DialogStateProvider>
+              </ModalStateProvider>
+            </InvitesStateProvider>
           </PrefsStateProvider>
         </ShellStateProvider>
       </SessionProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 5c4dc4e63..6af3c7d6f 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
+import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
+import {QueryProvider} from '#/lib/react-query'
 import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
+import {ThemeProvider} from '#/lib/ThemeContext'
 import {logger} from '#/logger'
+import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
+import {Provider as DialogStateProvider} from '#/state/dialogs'
+import {Provider as InvitesStateProvider} from '#/state/invites'
+import {Provider as LightboxStateProvider} from '#/state/lightbox'
 import {MessagesProvider} from '#/state/messages'
+import {Provider as ModalStateProvider} from '#/state/modals'
 import {init as initPersistedState} from '#/state/persisted'
+import {Provider as PrefsStateProvider} from '#/state/preferences'
 import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
 import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
-import {readLastActiveAccount} from '#/state/session/util'
-import {useIntentHandler} from 'lib/hooks/useIntentHandler'
-import {QueryProvider} from 'lib/react-query'
-import {ThemeProvider} from 'lib/ThemeContext'
-import {Provider as DialogStateProvider} from 'state/dialogs'
-import {Provider as InvitesStateProvider} from 'state/invites'
-import {Provider as LightboxStateProvider} from 'state/lightbox'
-import {Provider as ModalStateProvider} from 'state/modals'
-import {Provider as MutedThreadsProvider} from 'state/muted-threads'
-import {Provider as PrefsStateProvider} from 'state/preferences'
-import {Provider as UnreadNotifsProvider} from 'state/queries/notifications/unread'
+import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
 import {
   Provider as SessionProvider,
   SessionAccount,
   useSession,
   useSessionApi,
-} from 'state/session'
-import {Provider as ShellStateProvider} from 'state/shell'
-import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out'
-import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed'
-import * as Toast from 'view/com/util/Toast'
-import {ToastContainer} from 'view/com/util/Toast.web'
-import {Shell} from 'view/shell/index'
+} from '#/state/session'
+import {readLastActiveAccount} from '#/state/session/util'
+import {Provider as ShellStateProvider} from '#/state/shell'
+import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
+import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
+import * as Toast from '#/view/com/util/Toast'
+import {ToastContainer} from '#/view/com/util/Toast.web'
+import {Shell} from '#/view/shell/index'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {Provider as PortalProvider} from '#/components/Portal'
@@ -96,9 +96,11 @@ function InnerApp() {
                           <SelectedFeedProvider>
                             <UnreadNotifsProvider>
                               <BackgroundNotificationPreferencesProvider>
-                                <SafeAreaProvider>
-                                  <Shell />
-                                </SafeAreaProvider>
+                                <MutedThreadsProvider>
+                                  <SafeAreaProvider>
+                                    <Shell />
+                                  </SafeAreaProvider>
+                                </MutedThreadsProvider>
                               </BackgroundNotificationPreferencesProvider>
                             </UnreadNotifsProvider>
                           </SelectedFeedProvider>
@@ -136,21 +138,19 @@ function App() {
     <SessionProvider>
       <ShellStateProvider>
         <PrefsStateProvider>
-          <MutedThreadsProvider>
-            <InvitesStateProvider>
-              <ModalStateProvider>
-                <DialogStateProvider>
-                  <LightboxStateProvider>
-                    <I18nProvider>
-                      <PortalProvider>
-                        <InnerApp />
-                      </PortalProvider>
-                    </I18nProvider>
-                  </LightboxStateProvider>
-                </DialogStateProvider>
-              </ModalStateProvider>
-            </InvitesStateProvider>
-          </MutedThreadsProvider>
+          <InvitesStateProvider>
+            <ModalStateProvider>
+              <DialogStateProvider>
+                <LightboxStateProvider>
+                  <I18nProvider>
+                    <PortalProvider>
+                      <InnerApp />
+                    </PortalProvider>
+                  </I18nProvider>
+                </LightboxStateProvider>
+              </DialogStateProvider>
+            </ModalStateProvider>
+          </InvitesStateProvider>
         </PrefsStateProvider>
       </ShellStateProvider>
     </SessionProvider>
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 9939f60c9..0d77ec8a3 100644
--- a/src/lib/statsig/events.ts
+++ b/src/lib/statsig/events.ts
@@ -103,6 +103,8 @@ export type LogEvents = {
   'post:unrepost': {
     logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
   }
+  'post:mute': {}
+  'post:unmute': {}
   'profile:follow': {
     didBecomeMutual: boolean | undefined
     followeeClout: number | undefined
diff --git a/src/state/cache/thread-mutes.tsx b/src/state/cache/thread-mutes.tsx
new file mode 100644
index 000000000..b58bd430f
--- /dev/null
+++ b/src/state/cache/thread-mutes.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+type StateContext = Map<string, boolean>
+type SetStateContext = (uri: string, value: boolean) => void
+
+const stateContext = React.createContext<StateContext>(new Map())
+const setStateContext = React.createContext<SetStateContext>(
+  (_: string) => false,
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+  const [state, setState] = React.useState<StateContext>(() => new Map())
+
+  const setThreadMute = React.useCallback(
+    (uri: string, value: boolean) => {
+      setState(prev => {
+        const next = new Map(prev)
+        next.set(uri, value)
+        return next
+      })
+    },
+    [setState],
+  )
+  return (
+    <stateContext.Provider value={state}>
+      <setStateContext.Provider value={setThreadMute}>
+        {children}
+      </setStateContext.Provider>
+    </stateContext.Provider>
+  )
+}
+
+export function useMutedThreads() {
+  return React.useContext(stateContext)
+}
+
+export function useIsThreadMuted(uri: string, defaultValue = false) {
+  const state = React.useContext(stateContext)
+  return state.get(uri) ?? defaultValue
+}
+
+export function useSetThreadMute() {
+  return React.useContext(setStateContext)
+}
diff --git a/src/state/muted-threads.tsx b/src/state/muted-threads.tsx
deleted file mode 100644
index 84a717eb7..000000000
--- a/src/state/muted-threads.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import React from 'react'
-import * as persisted from '#/state/persisted'
-import {track} from '#/lib/analytics/analytics'
-
-type StateContext = persisted.Schema['mutedThreads']
-type ToggleContext = (uri: string) => boolean
-
-const stateContext = React.createContext<StateContext>(
-  persisted.defaults.mutedThreads,
-)
-const toggleContext = React.createContext<ToggleContext>((_: string) => false)
-
-export function Provider({children}: React.PropsWithChildren<{}>) {
-  const [state, setState] = React.useState(persisted.get('mutedThreads'))
-
-  const toggleThreadMute = React.useCallback(
-    (uri: string) => {
-      let muted = false
-      setState((arr: string[]) => {
-        if (arr.includes(uri)) {
-          arr = arr.filter(v => v !== uri)
-          muted = false
-          track('Post:ThreadUnmute')
-        } else {
-          arr = arr.concat([uri])
-          muted = true
-          track('Post:ThreadMute')
-        }
-        persisted.write('mutedThreads', arr)
-        return arr
-      })
-      return muted
-    },
-    [setState],
-  )
-
-  React.useEffect(() => {
-    return persisted.onUpdate(() => {
-      setState(persisted.get('mutedThreads'))
-    })
-  }, [setState])
-
-  return (
-    <stateContext.Provider value={state}>
-      <toggleContext.Provider value={toggleThreadMute}>
-        {children}
-      </toggleContext.Provider>
-    </stateContext.Provider>
-  )
-}
-
-export function useMutedThreads() {
-  return React.useContext(stateContext)
-}
-
-export function useToggleThreadMute() {
-  return React.useContext(toggleContext)
-}
-
-export function isThreadMuted(uri: string) {
-  return persisted.get('mutedThreads').includes(uri)
-}
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index d9f019af3..0607f07a1 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -26,7 +26,6 @@ import {
   useQueryClient,
 } from '@tanstack/react-query'
 
-import {useMutedThreads} from '#/state/muted-threads'
 import {useAgent} from '#/state/session'
 import {useModerationOpts} from '../../preferences/moderation-opts'
 import {STALE} from '..'
@@ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
   const agent = useAgent()
   const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
-  const threadMutes = useMutedThreads()
   const unreads = useUnreadNotificationsApi()
   const enabled = opts?.enabled !== false
   const lastPageCountRef = useRef(0)
@@ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) {
             cursor: pageParam,
             queryClient,
             moderationOpts,
-            threadMutes,
             fetchAdditionalData: true,
           })
         ).page
diff --git a/src/state/queries/notifications/unread.tsx b/src/state/queries/notifications/unread.tsx
index ffb8d03bc..7bb325ea9 100644
--- a/src/state/queries/notifications/unread.tsx
+++ b/src/state/queries/notifications/unread.tsx
@@ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3'
 
 import BroadcastChannel from '#/lib/broadcast'
 import {logger} from '#/logger'
-import {useMutedThreads} from '#/state/muted-threads'
 import {useAgent, useSession} from '#/state/session'
 import {resetBadgeCount} from 'lib/notifications/notifications'
 import {useModerationOpts} from '../../preferences/moderation-opts'
@@ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
   const agent = useAgent()
   const queryClient = useQueryClient()
   const moderationOpts = useModerationOpts()
-  const threadMutes = useMutedThreads()
 
   const [numUnread, setNumUnread] = React.useState('')
 
@@ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
             limit: 40,
             queryClient,
             moderationOpts,
-            threadMutes,
 
             // only fetch subjects when the page is going to be used
             // in the notifications query, otherwise skip it
@@ -192,7 +189,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
         }
       },
     }
-  }, [setNumUnread, queryClient, moderationOpts, threadMutes, agent])
+  }, [setNumUnread, queryClient, moderationOpts, agent])
   checkUnreadRef.current = api.checkUnread
 
   return (
diff --git a/src/state/queries/notifications/util.ts b/src/state/queries/notifications/util.ts
index 466249353..8ed1c0390 100644
--- a/src/state/queries/notifications/util.ts
+++ b/src/state/queries/notifications/util.ts
@@ -1,5 +1,4 @@
 import {
-  AppBskyEmbedRecord,
   AppBskyFeedDefs,
   AppBskyFeedLike,
   AppBskyFeedPost,
@@ -28,7 +27,6 @@ export async function fetchPage({
   limit,
   queryClient,
   moderationOpts,
-  threadMutes,
   fetchAdditionalData,
 }: {
   agent: BskyAgent
@@ -36,7 +34,6 @@ export async function fetchPage({
   limit: number
   queryClient: QueryClient
   moderationOpts: ModerationOpts | undefined
-  threadMutes: string[]
   fetchAdditionalData: boolean
 }): Promise<{page: FeedPage; indexedAt: string | undefined}> {
   const res = await agent.listNotifications({
@@ -67,11 +64,6 @@ export async function fetchPage({
     }
   }
 
-  // apply thread muting
-  notifsGrouped = notifsGrouped.filter(
-    notif => !isThreadMuted(notif, threadMutes),
-  )
-
   let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date()
   if (Number.isNaN(seenAt.getTime())) {
     seenAt = new Date()
@@ -207,45 +199,3 @@ function getSubjectUri(
     return notif.reasonSubject
   }
 }
-
-export function isThreadMuted(notif: FeedNotification, threadMutes: string[]) {
-  // If there's a subject we want to use that. This will always work on the notifications tab
-  if (notif.subject) {
-    const record = notif.subject.record as AppBskyFeedPost.Record
-    // Check for a quote record
-    if (
-      (record.reply && threadMutes.includes(record.reply.root.uri)) ||
-      (notif.subject.uri && threadMutes.includes(notif.subject.uri))
-    ) {
-      return true
-    } else if (
-      AppBskyEmbedRecord.isMain(record.embed) &&
-      threadMutes.includes(record.embed.record.uri)
-    ) {
-      return true
-    }
-  } else {
-    // Otherwise we just do the best that we can
-    const record = notif.notification.record
-    if (AppBskyFeedPost.isRecord(record)) {
-      if (record.reply && threadMutes.includes(record.reply.root.uri)) {
-        // We can always filter replies
-        return true
-      } else if (
-        AppBskyEmbedRecord.isMain(record.embed) &&
-        threadMutes.includes(record.embed.record.uri)
-      ) {
-        // We can also filter quotes if the quoted post is the root
-        return true
-      }
-    } else if (
-      AppBskyFeedRepost.isRecord(record) &&
-      threadMutes.includes(record.subject.uri)
-    ) {
-      // Finally we can filter reposts, again if the post is the root
-      return true
-    }
-  }
-
-  return false
-}
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 794f48eb1..8e77bf6b9 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig'
 import {updatePostShadow} from '#/state/cache/post-shadow'
 import {Shadow} from '#/state/cache/types'
 import {useAgent, useSession} from '#/state/session'
+import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
 import {findProfileQueryData} from './profile'
 
 const RQKEY_ROOT = 'post'
@@ -291,3 +292,72 @@ export function usePostDeleteMutation() {
     },
   })
 }
+
+export function useThreadMuteMutationQueue(
+  post: Shadow<AppBskyFeedDefs.PostView>,
+  rootUri: string,
+) {
+  const threadMuteMutation = useThreadMuteMutation()
+  const threadUnmuteMutation = useThreadUnmuteMutation()
+  const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
+  const setThreadMute = useSetThreadMute()
+
+  const queueToggle = useToggleMutationQueue<boolean>({
+    initialState: isThreadMuted,
+    runMutation: async (_prev, shouldLike) => {
+      if (shouldLike) {
+        await threadMuteMutation.mutateAsync({
+          uri: rootUri,
+        })
+        return true
+      } else {
+        await threadUnmuteMutation.mutateAsync({
+          uri: rootUri,
+        })
+        return false
+      }
+    },
+    onSuccess(finalIsMuted) {
+      // finalize
+      setThreadMute(rootUri, finalIsMuted)
+    },
+  })
+
+  const queueMuteThread = useCallback(() => {
+    // optimistically update
+    setThreadMute(rootUri, true)
+    return queueToggle(true)
+  }, [setThreadMute, rootUri, queueToggle])
+
+  const queueUnmuteThread = useCallback(() => {
+    // optimistically update
+    setThreadMute(rootUri, false)
+    return queueToggle(false)
+  }, [rootUri, setThreadMute, queueToggle])
+
+  return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const
+}
+
+function useThreadMuteMutation() {
+  const agent = useAgent()
+  return useMutation<
+    {},
+    Error,
+    {uri: string} // the root post's uri
+  >({
+    mutationFn: ({uri}) => {
+      logEvent('post:mute', {})
+      return agent.api.app.bsky.graph.muteThread({root: uri})
+    },
+  })
+}
+
+function useThreadUnmuteMutation() {
+  const agent = useAgent()
+  return useMutation<{}, Error, {uri: string}>({
+    mutationFn: ({uri}) => {
+      logEvent('post:unmute', {})
+      return agent.api.app.bsky.graph.unmuteThread({root: uri})
+    },
+  })
+}
diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx
index 2486b73d5..45e00e58c 100644
--- a/src/view/com/util/forms/PostDropdownBtn.tsx
+++ b/src/view/com/util/forms/PostDropdownBtn.tsx
@@ -7,7 +7,7 @@ import {
 } from 'react-native'
 import * as Clipboard from 'expo-clipboard'
 import {
-  AppBskyActorDefs,
+  AppBskyFeedDefs,
   AppBskyFeedPost,
   AtUri,
   RichText as RichTextAPI,
@@ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers'
 import {getTranslatorLink} from '#/locale/helpers'
 import {logger} from '#/logger'
 import {isWeb} from '#/platform/detection'
+import {Shadow} from '#/state/cache/post-shadow'
 import {useFeedFeedbackContext} from '#/state/feed-feedback'
-import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads'
 import {useLanguagePrefs} from '#/state/preferences'
 import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences'
 import {useOpenLink} from '#/state/preferences/in-app-browser'
-import {usePostDeleteMutation} from '#/state/queries/post'
+import {
+  usePostDeleteMutation,
+  useThreadMuteMutationQueue,
+} from '#/state/queries/post'
 import {useSession} from '#/state/session'
 import {getCurrentRoute} from 'lib/routes/helpers'
 import {shareUrl} from 'lib/sharing'
@@ -62,9 +65,7 @@ import * as Toast from '../Toast'
 
 let PostDropdownBtn = ({
   testID,
-  postAuthor,
-  postCid,
-  postUri,
+  post,
   postFeedContext,
   record,
   richText,
@@ -74,9 +75,7 @@ let PostDropdownBtn = ({
   timestamp,
 }: {
   testID: string
-  postAuthor: AppBskyActorDefs.ProfileViewBasic
-  postCid: string
-  postUri: string
+  post: Shadow<AppBskyFeedDefs.PostView>
   postFeedContext: string | undefined
   record: AppBskyFeedPost.Record
   richText: RichTextAPI
@@ -92,8 +91,6 @@ let PostDropdownBtn = ({
   const {_} = useLingui()
   const defaultCtrlColor = theme.palette.default.postCtrl
   const langPrefs = useLanguagePrefs()
-  const mutedThreads = useMutedThreads()
-  const toggleThreadMute = useToggleThreadMute()
   const postDeleteMutation = usePostDeleteMutation()
   const hiddenPosts = useHiddenPosts()
   const {hidePost} = useHiddenPostsApi()
@@ -107,9 +104,15 @@ let PostDropdownBtn = ({
   const loggedOutWarningPromptControl = useDialogControl()
   const embedPostControl = useDialogControl()
   const sendViaChatControl = useDialogControl()
+  const postUri = post.uri
+  const postCid = post.cid
+  const postAuthor = post.author
 
   const rootUri = record.reply?.root?.uri || postUri
-  const isThreadMuted = mutedThreads.includes(rootUri)
+  const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
+    post,
+    rootUri,
+  )
   const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
   const isAuthor = postAuthor.did === currentAccount?.did
 
@@ -162,18 +165,22 @@ let PostDropdownBtn = ({
 
   const onToggleThreadMute = React.useCallback(() => {
     try {
-      const muted = toggleThreadMute(rootUri)
-      if (muted) {
+      if (isThreadMuted) {
+        unmuteThread()
+        Toast.show(_(msg`You will now receive notifications for this thread`))
+      } else {
+        muteThread()
         Toast.show(
           _(msg`You will no longer receive notifications for this thread`),
         )
-      } else {
-        Toast.show(_(msg`You will now receive notifications for this thread`))
       }
-    } catch (e) {
-      logger.error('Failed to toggle thread mute', {message: e})
+    } catch (e: any) {
+      if (e?.name !== 'AbortError') {
+        logger.error('Failed to toggle thread mute', {message: e})
+        Toast.show(_(msg`Failed to toggle thread mute, please try again`))
+      }
     }
-  }, [rootUri, toggleThreadMute, _])
+  }, [isThreadMuted, unmuteThread, _, muteThread])
 
   const onCopyPostText = React.useCallback(() => {
     const str = richTextToString(richText, true)
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index c389855e3..c0e743db4 100644
--- a/src/view/com/util/post-ctrls/PostCtrls.tsx
+++ b/src/view/com/util/post-ctrls/PostCtrls.tsx
@@ -319,9 +319,7 @@ let PostCtrls = ({
       <View style={big ? a.align_center : [a.flex_1, a.align_start]}>
         <PostDropdownBtn
           testID="postDropdownBtn"
-          postAuthor={post.author}
-          postCid={post.cid}
-          postUri={post.uri}
+          post={post}
           postFeedContext={feedContext}
           record={record}
           richText={richText}
diff --git a/yarn.lock b/yarn.lock
index 51da5ea4f..67ff046cb 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -34,10 +34,10 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.12.18":
-  version "0.12.18"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821"
-  integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ==
+"@atproto/api@^0.12.19":
+  version "0.12.19"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.19.tgz#6d842269b6b9cd3fc5864e12824d4fb04cc033cf"
+  integrity sha512-dsiTpjqBhjGwNW/qG/tLSgUQnmOSvd8hsQr5d8GCUDGK2AEHWl0KNgLPbwxIBEIo8Jg9NHsvqV7BMoix8YreIg==
   dependencies:
     "@atproto/common-web" "^0.3.0"
     "@atproto/lexicon" "^0.4.0"