From 5f5d845053e13169f89fc70a3f858b0a9e5ed4fd Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 18 Jun 2024 19:48:34 +0100 Subject: [PATCH] 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 --- package.json | 2 +- src/App.native.tsx | 76 ++++++++++----------- src/App.web.tsx | 72 +++++++++---------- src/lib/statsig/events.ts | 2 + src/state/cache/thread-mutes.tsx | 44 ++++++++++++ src/state/muted-threads.tsx | 62 ----------------- src/state/queries/notifications/feed.ts | 3 - src/state/queries/notifications/unread.tsx | 5 +- src/state/queries/notifications/util.ts | 50 -------------- src/state/queries/post.ts | 70 +++++++++++++++++++ src/view/com/util/forms/PostDropdownBtn.tsx | 45 ++++++------ src/view/com/util/post-ctrls/PostCtrls.tsx | 4 +- yarn.lock | 8 +-- 13 files changed, 223 insertions(+), 220 deletions(-) create mode 100644 src/state/cache/thread-mutes.tsx delete mode 100644 src/state/muted-threads.tsx 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 = ({