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
This commit is contained in:
		
							parent
							
								
									35e54e24a0
								
							
						
					
					
						commit
						5f5d845053
					
				
					 13 changed files with 223 additions and 220 deletions
				
			
		|  | @ -49,7 +49,7 @@ | ||||||
|     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" |     "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@atproto/api": "^0.12.18", |     "@atproto/api": "^0.12.19", | ||||||
|     "@bam.tech/react-native-image-resizer": "^3.0.4", |     "@bam.tech/react-native-image-resizer": "^3.0.4", | ||||||
|     "@braintree/sanitize-url": "^6.0.2", |     "@braintree/sanitize-url": "^6.0.2", | ||||||
|     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", |     "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", | ||||||
|  |  | ||||||
|  | @ -14,40 +14,40 @@ import * as SplashScreen from 'expo-splash-screen' | ||||||
| import {msg} from '@lingui/macro' | import {msg} from '@lingui/macro' | ||||||
| import {useLingui} from '@lingui/react' | import {useLingui} from '@lingui/react' | ||||||
| 
 | 
 | ||||||
|  | import {useIntentHandler} from '#/lib/hooks/useIntentHandler' | ||||||
|  | import {QueryProvider} from '#/lib/react-query' | ||||||
| import { | import { | ||||||
|   initialize, |   initialize, | ||||||
|   Provider as StatsigProvider, |   Provider as StatsigProvider, | ||||||
|   tryFetchGates, |   tryFetchGates, | ||||||
| } from '#/lib/statsig/statsig' | } from '#/lib/statsig/statsig' | ||||||
|  | import {s} from '#/lib/styles' | ||||||
|  | import {ThemeProvider} from '#/lib/ThemeContext' | ||||||
| import {logger} from '#/logger' | 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 {MessagesProvider} from '#/state/messages' | ||||||
|  | import {Provider as ModalStateProvider} from '#/state/modals' | ||||||
| import {init as initPersistedState} from '#/state/persisted' | 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 LabelDefsProvider} from '#/state/preferences/label-defs' | ||||||
| import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' | import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' | ||||||
| import {readLastActiveAccount} from '#/state/session/util' | import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' | ||||||
| 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 { | import { | ||||||
|   Provider as SessionProvider, |   Provider as SessionProvider, | ||||||
|   SessionAccount, |   SessionAccount, | ||||||
|   useSession, |   useSession, | ||||||
|   useSessionApi, |   useSessionApi, | ||||||
| } from 'state/session' | } from '#/state/session' | ||||||
| import {Provider as ShellStateProvider} from 'state/shell' | import {readLastActiveAccount} from '#/state/session/util' | ||||||
| import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' | import {Provider as ShellStateProvider} from '#/state/shell' | ||||||
| import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' | import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' | ||||||
| import {TestCtrls} from 'view/com/testing/TestCtrls' | import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' | ||||||
| import * as Toast from 'view/com/util/Toast' | import {TestCtrls} from '#/view/com/testing/TestCtrls' | ||||||
| import {Shell} from 'view/shell' | import * as Toast from '#/view/com/util/Toast' | ||||||
|  | import {Shell} from '#/view/shell' | ||||||
| import {ThemeProvider as Alf} from '#/alf' | import {ThemeProvider as Alf} from '#/alf' | ||||||
| import {useColorModeTheme} from '#/alf/util/useColorModeTheme' | import {useColorModeTheme} from '#/alf/util/useColorModeTheme' | ||||||
| import {Provider as PortalProvider} from '#/components/Portal' | import {Provider as PortalProvider} from '#/components/Portal' | ||||||
|  | @ -112,10 +112,12 @@ function InnerApp() { | ||||||
|                             <SelectedFeedProvider> |                             <SelectedFeedProvider> | ||||||
|                               <UnreadNotifsProvider> |                               <UnreadNotifsProvider> | ||||||
|                                 <BackgroundNotificationPreferencesProvider> |                                 <BackgroundNotificationPreferencesProvider> | ||||||
|  |                                   <MutedThreadsProvider> | ||||||
|                                     <GestureHandlerRootView style={s.h100pct}> |                                     <GestureHandlerRootView style={s.h100pct}> | ||||||
|                                       <TestCtrls /> |                                       <TestCtrls /> | ||||||
|                                       <Shell /> |                                       <Shell /> | ||||||
|                                     </GestureHandlerRootView> |                                     </GestureHandlerRootView> | ||||||
|  |                                   </MutedThreadsProvider> | ||||||
|                                 </BackgroundNotificationPreferencesProvider> |                                 </BackgroundNotificationPreferencesProvider> | ||||||
|                               </UnreadNotifsProvider> |                               </UnreadNotifsProvider> | ||||||
|                             </SelectedFeedProvider> |                             </SelectedFeedProvider> | ||||||
|  | @ -154,7 +156,6 @@ function App() { | ||||||
|       <SessionProvider> |       <SessionProvider> | ||||||
|         <ShellStateProvider> |         <ShellStateProvider> | ||||||
|           <PrefsStateProvider> |           <PrefsStateProvider> | ||||||
|             <MutedThreadsProvider> |  | ||||||
|             <InvitesStateProvider> |             <InvitesStateProvider> | ||||||
|               <ModalStateProvider> |               <ModalStateProvider> | ||||||
|                 <DialogStateProvider> |                 <DialogStateProvider> | ||||||
|  | @ -168,7 +169,6 @@ function App() { | ||||||
|                 </DialogStateProvider> |                 </DialogStateProvider> | ||||||
|               </ModalStateProvider> |               </ModalStateProvider> | ||||||
|             </InvitesStateProvider> |             </InvitesStateProvider> | ||||||
|             </MutedThreadsProvider> |  | ||||||
|           </PrefsStateProvider> |           </PrefsStateProvider> | ||||||
|         </ShellStateProvider> |         </ShellStateProvider> | ||||||
|       </SessionProvider> |       </SessionProvider> | ||||||
|  |  | ||||||
|  | @ -8,35 +8,35 @@ import {SafeAreaProvider} from 'react-native-safe-area-context' | ||||||
| import {msg} from '@lingui/macro' | import {msg} from '@lingui/macro' | ||||||
| import {useLingui} from '@lingui/react' | 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 {Provider as StatsigProvider} from '#/lib/statsig/statsig' | ||||||
|  | import {ThemeProvider} from '#/lib/ThemeContext' | ||||||
| import {logger} from '#/logger' | 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 {MessagesProvider} from '#/state/messages' | ||||||
|  | import {Provider as ModalStateProvider} from '#/state/modals' | ||||||
| import {init as initPersistedState} from '#/state/persisted' | 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 LabelDefsProvider} from '#/state/preferences/label-defs' | ||||||
| import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' | import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts' | ||||||
| import {readLastActiveAccount} from '#/state/session/util' | import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread' | ||||||
| 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 { | import { | ||||||
|   Provider as SessionProvider, |   Provider as SessionProvider, | ||||||
|   SessionAccount, |   SessionAccount, | ||||||
|   useSession, |   useSession, | ||||||
|   useSessionApi, |   useSessionApi, | ||||||
| } from 'state/session' | } from '#/state/session' | ||||||
| import {Provider as ShellStateProvider} from 'state/shell' | import {readLastActiveAccount} from '#/state/session/util' | ||||||
| import {Provider as LoggedOutViewProvider} from 'state/shell/logged-out' | import {Provider as ShellStateProvider} from '#/state/shell' | ||||||
| import {Provider as SelectedFeedProvider} from 'state/shell/selected-feed' | import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' | ||||||
| import * as Toast from 'view/com/util/Toast' | import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' | ||||||
| import {ToastContainer} from 'view/com/util/Toast.web' | import * as Toast from '#/view/com/util/Toast' | ||||||
| import {Shell} from 'view/shell/index' | import {ToastContainer} from '#/view/com/util/Toast.web' | ||||||
|  | import {Shell} from '#/view/shell/index' | ||||||
| import {ThemeProvider as Alf} from '#/alf' | import {ThemeProvider as Alf} from '#/alf' | ||||||
| import {useColorModeTheme} from '#/alf/util/useColorModeTheme' | import {useColorModeTheme} from '#/alf/util/useColorModeTheme' | ||||||
| import {Provider as PortalProvider} from '#/components/Portal' | import {Provider as PortalProvider} from '#/components/Portal' | ||||||
|  | @ -96,9 +96,11 @@ function InnerApp() { | ||||||
|                           <SelectedFeedProvider> |                           <SelectedFeedProvider> | ||||||
|                             <UnreadNotifsProvider> |                             <UnreadNotifsProvider> | ||||||
|                               <BackgroundNotificationPreferencesProvider> |                               <BackgroundNotificationPreferencesProvider> | ||||||
|  |                                 <MutedThreadsProvider> | ||||||
|                                   <SafeAreaProvider> |                                   <SafeAreaProvider> | ||||||
|                                     <Shell /> |                                     <Shell /> | ||||||
|                                   </SafeAreaProvider> |                                   </SafeAreaProvider> | ||||||
|  |                                 </MutedThreadsProvider> | ||||||
|                               </BackgroundNotificationPreferencesProvider> |                               </BackgroundNotificationPreferencesProvider> | ||||||
|                             </UnreadNotifsProvider> |                             </UnreadNotifsProvider> | ||||||
|                           </SelectedFeedProvider> |                           </SelectedFeedProvider> | ||||||
|  | @ -136,7 +138,6 @@ function App() { | ||||||
|     <SessionProvider> |     <SessionProvider> | ||||||
|       <ShellStateProvider> |       <ShellStateProvider> | ||||||
|         <PrefsStateProvider> |         <PrefsStateProvider> | ||||||
|           <MutedThreadsProvider> |  | ||||||
|           <InvitesStateProvider> |           <InvitesStateProvider> | ||||||
|             <ModalStateProvider> |             <ModalStateProvider> | ||||||
|               <DialogStateProvider> |               <DialogStateProvider> | ||||||
|  | @ -150,7 +151,6 @@ function App() { | ||||||
|               </DialogStateProvider> |               </DialogStateProvider> | ||||||
|             </ModalStateProvider> |             </ModalStateProvider> | ||||||
|           </InvitesStateProvider> |           </InvitesStateProvider> | ||||||
|           </MutedThreadsProvider> |  | ||||||
|         </PrefsStateProvider> |         </PrefsStateProvider> | ||||||
|       </ShellStateProvider> |       </ShellStateProvider> | ||||||
|     </SessionProvider> |     </SessionProvider> | ||||||
|  |  | ||||||
|  | @ -103,6 +103,8 @@ export type LogEvents = { | ||||||
|   'post:unrepost': { |   'post:unrepost': { | ||||||
|     logContext: 'FeedItem' | 'PostThreadItem' | 'Post' |     logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | ||||||
|   } |   } | ||||||
|  |   'post:mute': {} | ||||||
|  |   'post:unmute': {} | ||||||
|   'profile:follow': { |   'profile:follow': { | ||||||
|     didBecomeMutual: boolean | undefined |     didBecomeMutual: boolean | undefined | ||||||
|     followeeClout: number | undefined |     followeeClout: number | undefined | ||||||
|  |  | ||||||
							
								
								
									
										44
									
								
								src/state/cache/thread-mutes.tsx
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								src/state/cache/thread-mutes.tsx
									
										
									
									
										vendored
									
									
										Normal file
									
								
							|  | @ -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) | ||||||
|  | } | ||||||
|  | @ -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) |  | ||||||
| } |  | ||||||
|  | @ -26,7 +26,6 @@ import { | ||||||
|   useQueryClient, |   useQueryClient, | ||||||
| } from '@tanstack/react-query' | } from '@tanstack/react-query' | ||||||
| 
 | 
 | ||||||
| import {useMutedThreads} from '#/state/muted-threads' |  | ||||||
| import {useAgent} from '#/state/session' | import {useAgent} from '#/state/session' | ||||||
| import {useModerationOpts} from '../../preferences/moderation-opts' | import {useModerationOpts} from '../../preferences/moderation-opts' | ||||||
| import {STALE} from '..' | import {STALE} from '..' | ||||||
|  | @ -54,7 +53,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { | ||||||
|   const agent = useAgent() |   const agent = useAgent() | ||||||
|   const queryClient = useQueryClient() |   const queryClient = useQueryClient() | ||||||
|   const moderationOpts = useModerationOpts() |   const moderationOpts = useModerationOpts() | ||||||
|   const threadMutes = useMutedThreads() |  | ||||||
|   const unreads = useUnreadNotificationsApi() |   const unreads = useUnreadNotificationsApi() | ||||||
|   const enabled = opts?.enabled !== false |   const enabled = opts?.enabled !== false | ||||||
|   const lastPageCountRef = useRef(0) |   const lastPageCountRef = useRef(0) | ||||||
|  | @ -82,7 +80,6 @@ export function useNotificationFeedQuery(opts?: {enabled?: boolean}) { | ||||||
|             cursor: pageParam, |             cursor: pageParam, | ||||||
|             queryClient, |             queryClient, | ||||||
|             moderationOpts, |             moderationOpts, | ||||||
|             threadMutes, |  | ||||||
|             fetchAdditionalData: true, |             fetchAdditionalData: true, | ||||||
|           }) |           }) | ||||||
|         ).page |         ).page | ||||||
|  |  | ||||||
|  | @ -9,7 +9,6 @@ import EventEmitter from 'eventemitter3' | ||||||
| 
 | 
 | ||||||
| import BroadcastChannel from '#/lib/broadcast' | import BroadcastChannel from '#/lib/broadcast' | ||||||
| import {logger} from '#/logger' | import {logger} from '#/logger' | ||||||
| import {useMutedThreads} from '#/state/muted-threads' |  | ||||||
| import {useAgent, useSession} from '#/state/session' | import {useAgent, useSession} from '#/state/session' | ||||||
| import {resetBadgeCount} from 'lib/notifications/notifications' | import {resetBadgeCount} from 'lib/notifications/notifications' | ||||||
| import {useModerationOpts} from '../../preferences/moderation-opts' | import {useModerationOpts} from '../../preferences/moderation-opts' | ||||||
|  | @ -48,7 +47,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { | ||||||
|   const agent = useAgent() |   const agent = useAgent() | ||||||
|   const queryClient = useQueryClient() |   const queryClient = useQueryClient() | ||||||
|   const moderationOpts = useModerationOpts() |   const moderationOpts = useModerationOpts() | ||||||
|   const threadMutes = useMutedThreads() |  | ||||||
| 
 | 
 | ||||||
|   const [numUnread, setNumUnread] = React.useState('') |   const [numUnread, setNumUnread] = React.useState('') | ||||||
| 
 | 
 | ||||||
|  | @ -147,7 +145,6 @@ export function Provider({children}: React.PropsWithChildren<{}>) { | ||||||
|             limit: 40, |             limit: 40, | ||||||
|             queryClient, |             queryClient, | ||||||
|             moderationOpts, |             moderationOpts, | ||||||
|             threadMutes, |  | ||||||
| 
 | 
 | ||||||
|             // only fetch subjects when the page is going to be used
 |             // only fetch subjects when the page is going to be used
 | ||||||
|             // in the notifications query, otherwise skip it
 |             // 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 |   checkUnreadRef.current = api.checkUnread | ||||||
| 
 | 
 | ||||||
|   return ( |   return ( | ||||||
|  |  | ||||||
|  | @ -1,5 +1,4 @@ | ||||||
| import { | import { | ||||||
|   AppBskyEmbedRecord, |  | ||||||
|   AppBskyFeedDefs, |   AppBskyFeedDefs, | ||||||
|   AppBskyFeedLike, |   AppBskyFeedLike, | ||||||
|   AppBskyFeedPost, |   AppBskyFeedPost, | ||||||
|  | @ -28,7 +27,6 @@ export async function fetchPage({ | ||||||
|   limit, |   limit, | ||||||
|   queryClient, |   queryClient, | ||||||
|   moderationOpts, |   moderationOpts, | ||||||
|   threadMutes, |  | ||||||
|   fetchAdditionalData, |   fetchAdditionalData, | ||||||
| }: { | }: { | ||||||
|   agent: BskyAgent |   agent: BskyAgent | ||||||
|  | @ -36,7 +34,6 @@ export async function fetchPage({ | ||||||
|   limit: number |   limit: number | ||||||
|   queryClient: QueryClient |   queryClient: QueryClient | ||||||
|   moderationOpts: ModerationOpts | undefined |   moderationOpts: ModerationOpts | undefined | ||||||
|   threadMutes: string[] |  | ||||||
|   fetchAdditionalData: boolean |   fetchAdditionalData: boolean | ||||||
| }): Promise<{page: FeedPage; indexedAt: string | undefined}> { | }): Promise<{page: FeedPage; indexedAt: string | undefined}> { | ||||||
|   const res = await agent.listNotifications({ |   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() |   let seenAt = res.data.seenAt ? new Date(res.data.seenAt) : new Date() | ||||||
|   if (Number.isNaN(seenAt.getTime())) { |   if (Number.isNaN(seenAt.getTime())) { | ||||||
|     seenAt = new Date() |     seenAt = new Date() | ||||||
|  | @ -207,45 +199,3 @@ function getSubjectUri( | ||||||
|     return notif.reasonSubject |     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 |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ import {logEvent, LogEvents, toClout} from '#/lib/statsig/statsig' | ||||||
| import {updatePostShadow} from '#/state/cache/post-shadow' | import {updatePostShadow} from '#/state/cache/post-shadow' | ||||||
| import {Shadow} from '#/state/cache/types' | import {Shadow} from '#/state/cache/types' | ||||||
| import {useAgent, useSession} from '#/state/session' | import {useAgent, useSession} from '#/state/session' | ||||||
|  | import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes' | ||||||
| import {findProfileQueryData} from './profile' | import {findProfileQueryData} from './profile' | ||||||
| 
 | 
 | ||||||
| const RQKEY_ROOT = 'post' | 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}) | ||||||
|  |     }, | ||||||
|  |   }) | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import { | ||||||
| } from 'react-native' | } from 'react-native' | ||||||
| import * as Clipboard from 'expo-clipboard' | import * as Clipboard from 'expo-clipboard' | ||||||
| import { | import { | ||||||
|   AppBskyActorDefs, |   AppBskyFeedDefs, | ||||||
|   AppBskyFeedPost, |   AppBskyFeedPost, | ||||||
|   AtUri, |   AtUri, | ||||||
|   RichText as RichTextAPI, |   RichText as RichTextAPI, | ||||||
|  | @ -22,12 +22,15 @@ import {richTextToString} from '#/lib/strings/rich-text-helpers' | ||||||
| import {getTranslatorLink} from '#/locale/helpers' | import {getTranslatorLink} from '#/locale/helpers' | ||||||
| import {logger} from '#/logger' | import {logger} from '#/logger' | ||||||
| import {isWeb} from '#/platform/detection' | import {isWeb} from '#/platform/detection' | ||||||
|  | import {Shadow} from '#/state/cache/post-shadow' | ||||||
| import {useFeedFeedbackContext} from '#/state/feed-feedback' | import {useFeedFeedbackContext} from '#/state/feed-feedback' | ||||||
| import {useMutedThreads, useToggleThreadMute} from '#/state/muted-threads' |  | ||||||
| import {useLanguagePrefs} from '#/state/preferences' | import {useLanguagePrefs} from '#/state/preferences' | ||||||
| import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' | import {useHiddenPosts, useHiddenPostsApi} from '#/state/preferences' | ||||||
| import {useOpenLink} from '#/state/preferences/in-app-browser' | 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 {useSession} from '#/state/session' | ||||||
| import {getCurrentRoute} from 'lib/routes/helpers' | import {getCurrentRoute} from 'lib/routes/helpers' | ||||||
| import {shareUrl} from 'lib/sharing' | import {shareUrl} from 'lib/sharing' | ||||||
|  | @ -62,9 +65,7 @@ import * as Toast from '../Toast' | ||||||
| 
 | 
 | ||||||
| let PostDropdownBtn = ({ | let PostDropdownBtn = ({ | ||||||
|   testID, |   testID, | ||||||
|   postAuthor, |   post, | ||||||
|   postCid, |  | ||||||
|   postUri, |  | ||||||
|   postFeedContext, |   postFeedContext, | ||||||
|   record, |   record, | ||||||
|   richText, |   richText, | ||||||
|  | @ -74,9 +75,7 @@ let PostDropdownBtn = ({ | ||||||
|   timestamp, |   timestamp, | ||||||
| }: { | }: { | ||||||
|   testID: string |   testID: string | ||||||
|   postAuthor: AppBskyActorDefs.ProfileViewBasic |   post: Shadow<AppBskyFeedDefs.PostView> | ||||||
|   postCid: string |  | ||||||
|   postUri: string |  | ||||||
|   postFeedContext: string | undefined |   postFeedContext: string | undefined | ||||||
|   record: AppBskyFeedPost.Record |   record: AppBskyFeedPost.Record | ||||||
|   richText: RichTextAPI |   richText: RichTextAPI | ||||||
|  | @ -92,8 +91,6 @@ let PostDropdownBtn = ({ | ||||||
|   const {_} = useLingui() |   const {_} = useLingui() | ||||||
|   const defaultCtrlColor = theme.palette.default.postCtrl |   const defaultCtrlColor = theme.palette.default.postCtrl | ||||||
|   const langPrefs = useLanguagePrefs() |   const langPrefs = useLanguagePrefs() | ||||||
|   const mutedThreads = useMutedThreads() |  | ||||||
|   const toggleThreadMute = useToggleThreadMute() |  | ||||||
|   const postDeleteMutation = usePostDeleteMutation() |   const postDeleteMutation = usePostDeleteMutation() | ||||||
|   const hiddenPosts = useHiddenPosts() |   const hiddenPosts = useHiddenPosts() | ||||||
|   const {hidePost} = useHiddenPostsApi() |   const {hidePost} = useHiddenPostsApi() | ||||||
|  | @ -107,9 +104,15 @@ let PostDropdownBtn = ({ | ||||||
|   const loggedOutWarningPromptControl = useDialogControl() |   const loggedOutWarningPromptControl = useDialogControl() | ||||||
|   const embedPostControl = useDialogControl() |   const embedPostControl = useDialogControl() | ||||||
|   const sendViaChatControl = useDialogControl() |   const sendViaChatControl = useDialogControl() | ||||||
|  |   const postUri = post.uri | ||||||
|  |   const postCid = post.cid | ||||||
|  |   const postAuthor = post.author | ||||||
| 
 | 
 | ||||||
|   const rootUri = record.reply?.root?.uri || postUri |   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 isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) | ||||||
|   const isAuthor = postAuthor.did === currentAccount?.did |   const isAuthor = postAuthor.did === currentAccount?.did | ||||||
| 
 | 
 | ||||||
|  | @ -162,18 +165,22 @@ let PostDropdownBtn = ({ | ||||||
| 
 | 
 | ||||||
|   const onToggleThreadMute = React.useCallback(() => { |   const onToggleThreadMute = React.useCallback(() => { | ||||||
|     try { |     try { | ||||||
|       const muted = toggleThreadMute(rootUri) |       if (isThreadMuted) { | ||||||
|       if (muted) { |         unmuteThread() | ||||||
|  |         Toast.show(_(msg`You will now receive notifications for this thread`)) | ||||||
|  |       } else { | ||||||
|  |         muteThread() | ||||||
|         Toast.show( |         Toast.show( | ||||||
|           _(msg`You will no longer receive notifications for this thread`), |           _(msg`You will no longer receive notifications for this thread`), | ||||||
|         ) |         ) | ||||||
|       } else { |  | ||||||
|         Toast.show(_(msg`You will now receive notifications for this thread`)) |  | ||||||
|       } |       } | ||||||
|     } catch (e) { |     } catch (e: any) { | ||||||
|  |       if (e?.name !== 'AbortError') { | ||||||
|         logger.error('Failed to toggle thread mute', {message: e}) |         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 onCopyPostText = React.useCallback(() => { | ||||||
|     const str = richTextToString(richText, true) |     const str = richTextToString(richText, true) | ||||||
|  |  | ||||||
|  | @ -319,9 +319,7 @@ let PostCtrls = ({ | ||||||
|       <View style={big ? a.align_center : [a.flex_1, a.align_start]}> |       <View style={big ? a.align_center : [a.flex_1, a.align_start]}> | ||||||
|         <PostDropdownBtn |         <PostDropdownBtn | ||||||
|           testID="postDropdownBtn" |           testID="postDropdownBtn" | ||||||
|           postAuthor={post.author} |           post={post} | ||||||
|           postCid={post.cid} |  | ||||||
|           postUri={post.uri} |  | ||||||
|           postFeedContext={feedContext} |           postFeedContext={feedContext} | ||||||
|           record={record} |           record={record} | ||||||
|           richText={richText} |           richText={richText} | ||||||
|  |  | ||||||
|  | @ -34,10 +34,10 @@ | ||||||
|     jsonpointer "^5.0.0" |     jsonpointer "^5.0.0" | ||||||
|     leven "^3.1.0" |     leven "^3.1.0" | ||||||
| 
 | 
 | ||||||
| "@atproto/api@^0.12.18": | "@atproto/api@^0.12.19": | ||||||
|   version "0.12.18" |   version "0.12.19" | ||||||
|   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" |   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.19.tgz#6d842269b6b9cd3fc5864e12824d4fb04cc033cf" | ||||||
|   integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== |   integrity sha512-dsiTpjqBhjGwNW/qG/tLSgUQnmOSvd8hsQr5d8GCUDGK2AEHWl0KNgLPbwxIBEIo8Jg9NHsvqV7BMoix8YreIg== | ||||||
|   dependencies: |   dependencies: | ||||||
|     "@atproto/common-web" "^0.3.0" |     "@atproto/common-web" "^0.3.0" | ||||||
|     "@atproto/lexicon" "^0.4.0" |     "@atproto/lexicon" "^0.4.0" | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue