diff --git a/package.json b/package.json
index 29e198c9..41783690 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 322e944a..18461fdd 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() {
-
-
-
-
+
+
+
+
+
+
@@ -154,21 +156,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 5c4dc4e6..6af3c7d6 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() {
-
-
-
+
+
+
+
+
@@ -136,21 +138,19 @@ function App() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts
index 9939f60c..0d77ec8a 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 00000000..b58bd430
--- /dev/null
+++ b/src/state/cache/thread-mutes.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+type StateContext = Map
+type SetStateContext = (uri: string, value: boolean) => void
+
+const stateContext = React.createContext(new Map())
+const setStateContext = React.createContext(
+ (_: string) => false,
+)
+
+export function Provider({children}: React.PropsWithChildren<{}>) {
+ const [state, setState] = React.useState(() => 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 (
+
+
+ {children}
+
+
+ )
+}
+
+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 84a717eb..00000000
--- 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(
- persisted.defaults.mutedThreads,
-)
-const toggleContext = React.createContext((_: 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 (
-
-
- {children}
-
-
- )
-}
-
-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 d9f019af..0607f07a 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 ffb8d03b..7bb325ea 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 46624935..8ed1c039 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 794f48eb..8e77bf6b 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,
+ rootUri: string,
+) {
+ const threadMuteMutation = useThreadMuteMutation()
+ const threadUnmuteMutation = useThreadUnmuteMutation()
+ const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
+ const setThreadMute = useSetThreadMute()
+
+ const queueToggle = useToggleMutationQueue({
+ 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 2486b73d..45e00e58 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
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 c389855e..c0e743db 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 = ({