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:
Samuel Newman 2024-06-18 19:48:34 +01:00 committed by GitHub
parent 35e54e24a0
commit 5f5d845053
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 223 additions and 220 deletions

44
src/state/cache/thread-mutes.tsx vendored Normal file
View 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)
}

View file

@ -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)
}

View file

@ -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

View file

@ -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 (

View file

@ -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
}

View file

@ -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})
},
})
}