Optimistic hidden replies (#4977)

zio/stable
Eric Bailey 2024-08-23 14:35:48 -05:00 committed by GitHub
parent 5ec8761b29
commit 425dd5f27f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 70 additions and 129 deletions

View File

@ -21,7 +21,6 @@ export interface PostShadow {
repostUri: string | undefined repostUri: string | undefined
isDeleted: boolean isDeleted: boolean
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
threadgateView: AppBskyFeedDefs.ThreadgateView | undefined
} }
export const POST_TOMBSTONE = Symbol('PostTombstone') export const POST_TOMBSTONE = Symbol('PostTombstone')
@ -105,16 +104,6 @@ function mergeShadow(
} }
} }
let threadgateView: typeof post.threadgate
if ('threadgateView' in shadow && !post.threadgate) {
if (
AppBskyFeedDefs.isThreadgateView(shadow.threadgateView) ||
shadow.threadgateView === undefined
) {
threadgateView = shadow.threadgateView
}
}
return castAsShadow({ return castAsShadow({
...post, ...post,
embed: embed || post.embed, embed: embed || post.embed,
@ -125,8 +114,6 @@ function mergeShadow(
like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like, like: 'likeUri' in shadow ? shadow.likeUri : post.viewer?.like,
repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost, repost: 'repostUri' in shadow ? shadow.repostUri : post.viewer?.repost,
}, },
// always prefer real post data
threadgate: post.threadgate || threadgateView,
}) })
} }

View File

@ -88,7 +88,10 @@ export type ThreadModerationCache = WeakMap<ThreadNode, ModerationDecision>
export function usePostThreadQuery(uri: string | undefined) { export function usePostThreadQuery(uri: string | undefined) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const agent = useAgent() const agent = useAgent()
return useQuery<ThreadNode, Error>({ return useQuery<
{thread: ThreadNode; threadgate?: AppBskyFeedDefs.ThreadgateView},
Error
>({
gcTime: 0, gcTime: 0,
queryKey: RQKEY(uri || ''), queryKey: RQKEY(uri || ''),
async queryFn() { async queryFn() {
@ -99,16 +102,21 @@ export function usePostThreadQuery(uri: string | undefined) {
if (res.success) { if (res.success) {
const thread = responseToThreadNodes(res.data.thread) const thread = responseToThreadNodes(res.data.thread)
annotateSelfThread(thread) annotateSelfThread(thread)
return thread return {
thread,
threadgate: res.data.threadgate as
| AppBskyFeedDefs.ThreadgateView
| undefined,
}
} }
return {type: 'unknown', uri: uri!} return {thread: {type: 'unknown', uri: uri!}}
}, },
enabled: !!uri, enabled: !!uri,
placeholderData: () => { placeholderData: () => {
if (!uri) return if (!uri) return
const post = findPostInQueryData(queryClient, uri) const post = findPostInQueryData(queryClient, uri)
if (post) { if (post) {
return post return {thread: post}
} }
return undefined return undefined
}, },

View File

@ -9,12 +9,10 @@ import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {networkRetry, retry} from '#/lib/async/retry' import {networkRetry, retry} from '#/lib/async/retry'
import {until} from '#/lib/async/until' import {until} from '#/lib/async/until'
import {updatePostShadow} from '#/state/cache/post-shadow'
import {STALE} from '#/state/queries' import {STALE} from '#/state/queries'
import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread' import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
import { import {
createTempThreadgateView,
createThreadgateRecord, createThreadgateRecord,
mergeThreadgateRecords, mergeThreadgateRecords,
threadgateAllowUISettingToAllowRecordValue, threadgateAllowUISettingToAllowRecordValue,
@ -33,18 +31,16 @@ export const createThreadgateRecordQueryKey = (uri: string) => [
] ]
export function useThreadgateRecordQuery({ export function useThreadgateRecordQuery({
enabled,
postUri, postUri,
initialData, initialData,
}: { }: {
enabled?: boolean
postUri?: string postUri?: string
initialData?: AppBskyFeedThreadgate.Record initialData?: AppBskyFeedThreadgate.Record
} = {}) { } = {}) {
const agent = useAgent() const agent = useAgent()
return useQuery({ return useQuery({
enabled: enabled ?? !!postUri, enabled: !!postUri,
queryKey: createThreadgateRecordQueryKey(postUri || ''), queryKey: createThreadgateRecordQueryKey(postUri || ''),
placeholderData: initialData, placeholderData: initialData,
staleTime: STALE.MINUTES.ONE, staleTime: STALE.MINUTES.ONE,
@ -344,26 +340,17 @@ export function useToggleReplyVisibilityMutation() {
} }
}) })
}, },
onSuccess(_, {postUri, replyUri}) { onSuccess() {
updatePostShadow(queryClient, postUri, {
threadgateView: createTempThreadgateView({
postUri,
hiddenReplies: [replyUri],
}),
})
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [threadgateRecordQueryKeyRoot], queryKey: [threadgateRecordQueryKeyRoot],
}) })
}, },
onError(_, {postUri, replyUri, action}) { onError(_, {replyUri, action}) {
if (action === 'hide') { if (action === 'hide') {
hiddenReplies.removeHiddenReplyUri(replyUri) hiddenReplies.removeHiddenReplyUri(replyUri)
} else if (action === 'show') { } else if (action === 'show') {
hiddenReplies.addHiddenReplyUri(replyUri) hiddenReplies.addHiddenReplyUri(replyUri)
} }
updatePostShadow(queryClient, postUri, {
threadgateView: undefined,
})
}, },
}) })
} }

View File

@ -139,23 +139,3 @@ export function createThreadgateRecord(
hiddenReplies: threadgate.hiddenReplies || [], hiddenReplies: threadgate.hiddenReplies || [],
} }
} }
export function createTempThreadgateView({
postUri,
hiddenReplies,
}: Pick<AppBskyFeedThreadgate.Record, 'hiddenReplies'> & {
postUri: string
}): AppBskyFeedDefs.ThreadgateView {
const record: AppBskyFeedThreadgate.Record = {
$type: 'app.bsky.feed.threadgate',
post: postUri,
allow: undefined,
hiddenReplies,
createdAt: new Date().toISOString(),
}
return {
$type: 'app.bsky.feed.defs#threadgateView',
uri: postUri,
record,
}
}

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {AppBskyFeedThreadgate} from '@atproto/api'
type StateContext = { type StateContext = {
uris: Set<string> uris: Set<string>
@ -67,3 +68,18 @@ export function useThreadgateHiddenReplyUris() {
export function useThreadgateHiddenReplyUrisAPI() { export function useThreadgateHiddenReplyUrisAPI() {
return React.useContext(ApiContext) return React.useContext(ApiContext)
} }
export function useMergedThreadgateHiddenReplies({
threadgateRecord,
}: {
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const {uris, recentlyUnhiddenUris} = useThreadgateHiddenReplyUris()
return React.useMemo(() => {
const set = new Set([...(threadgateRecord?.hiddenReplies || []), ...uris])
for (const uri of recentlyUnhiddenUris) {
set.delete(uri)
}
return set
}, [uris, recentlyUnhiddenUris, threadgateRecord])
}

View File

@ -3,12 +3,7 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated' import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import { import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -28,9 +23,9 @@ import {
usePostThreadQuery, usePostThreadQuery,
} from '#/state/queries/post-thread' } from '#/state/queries/post-thread'
import {usePreferencesQuery} from '#/state/queries/preferences' import {usePreferencesQuery} from '#/state/queries/preferences'
import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell' import {useComposerControls} from '#/state/shell'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform' import {useMinimalShellFabTransform} from 'lib/hooks/useMinimalShellTransform'
import {useSetTitle} from 'lib/hooks/useSetTitle' import {useSetTitle} from 'lib/hooks/useSetTitle'
@ -108,7 +103,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
isError: isThreadError, isError: isThreadError,
error: threadError, error: threadError,
refetch, refetch,
data: thread, data: {thread, threadgate} = {},
} = usePostThreadQuery(uri) } = usePostThreadQuery(uri)
const treeView = React.useMemo( const treeView = React.useMemo(
@ -119,26 +114,11 @@ export function PostThread({uri}: {uri: string | undefined}) {
) )
const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
const replyRef = const threadgateRecord = threadgate?.record as
rootPostRecord && AppBskyFeedPost.isRecord(rootPostRecord)
? rootPostRecord.reply
: undefined
const rootPostUri = replyRef ? replyRef.root.uri : rootPost?.uri
const isOP =
currentAccount &&
rootPostUri &&
currentAccount?.did === new AtUri(rootPostUri).host
const initialThreadgateRecord = rootPost?.threadgate?.record as
| AppBskyFeedThreadgate.Record | AppBskyFeedThreadgate.Record
| undefined | undefined
const {data: threadgateRecord} = useThreadgateRecordQuery({ const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
/** threadgateRecord,
* If the user is the OP and we have a root post, fetch the threadgate.
*/
enabled: Boolean(isOP && rootPostUri),
postUri: rootPostUri,
initialData: initialThreadgateRecord,
}) })
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
@ -194,9 +174,6 @@ export function PostThread({uri}: {uri: string | undefined}) {
const skeleton = React.useMemo(() => { const skeleton = React.useMemo(() => {
const threadViewPrefs = preferences?.threadViewPrefs const threadViewPrefs = preferences?.threadViewPrefs
if (!threadViewPrefs || !thread) return null if (!threadViewPrefs || !thread) return null
const threadgateRecordHiddenReplies = new Set<string>(
threadgateRecord?.hiddenReplies || [],
)
return createThreadSkeleton( return createThreadSkeleton(
sortThread( sortThread(
@ -205,13 +182,13 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache, threadModerationCache,
currentDid, currentDid,
justPostedUris, justPostedUris,
threadgateRecordHiddenReplies, threadgateHiddenReplies,
), ),
currentDid, currentDid,
treeView, treeView,
threadModerationCache, threadModerationCache,
hiddenRepliesState !== HiddenRepliesState.Hide, hiddenRepliesState !== HiddenRepliesState.Hide,
threadgateRecordHiddenReplies, threadgateHiddenReplies,
) )
}, [ }, [
thread, thread,
@ -221,7 +198,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache, threadModerationCache,
hiddenRepliesState, hiddenRepliesState,
justPostedUris, justPostedUris,
threadgateRecord, threadgateHiddenReplies,
]) ])
const error = React.useMemo(() => { const error = React.useMemo(() => {

View File

@ -17,6 +17,7 @@ import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {ThreadPost} from '#/state/queries/post-thread' import {ThreadPost} from '#/state/queries/post-thread'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
@ -206,24 +207,22 @@ let PostThreadItemLoaded = ({
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author]) }, [post.uri, post.author])
const repostsTitle = _(msg`Reposts of this post`) const repostsTitle = _(msg`Reposts of this post`)
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes( const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
post.uri, const isControlledByViewer = new AtUri(rootUri).host === currentAccount?.did
) return isControlledByViewer && isPostHiddenByThreadgate
const isControlledByViewer =
threadgateRecord &&
new AtUri(threadgateRecord.post).host === currentAccount?.did
if (!isControlledByViewer) return []
return threadgateRecord && isPostHiddenByThreadgate
? [ ? [
{ {
type: 'reply-hidden', type: 'reply-hidden',
source: {type: 'user', did: new AtUri(threadgateRecord.post).host}, source: {type: 'user', did: currentAccount?.did},
priority: 6, priority: 6,
}, },
] ]
: [] : []
}, [post, threadgateRecord, currentAccount?.did]) }, [post, currentAccount?.did, threadgateHiddenReplies, rootUri])
const quotesHref = React.useMemo(() => { const quotesHref = React.useMemo(() => {
const urip = new AtUri(post.uri) const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')

View File

@ -22,7 +22,7 @@ import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback' import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
@ -227,6 +227,10 @@ let FeedItemInner = ({
AppBskyFeedDefs.isReasonRepost(reason) && AppBskyFeedDefs.isReasonRepost(reason) &&
reason.by.did === currentAccount?.did reason.by.did === currentAccount?.did
/**
* If `post[0]` in this slice is the actual root post (not an orphan thread),
* then we may have a threadgate record to reference
*/
const threadgateRecord = AppBskyFeedThreadgate.isRecord( const threadgateRecord = AppBskyFeedThreadgate.isRecord(
rootPost.threadgate?.record, rootPost.threadgate?.record,
) )
@ -422,41 +426,26 @@ let PostContent = ({
const [limitLines, setLimitLines] = useState( const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES, () => countLines(richText.text) >= MAX_POST_LINES,
) )
const {uris: hiddenReplyUris, recentlyUnhiddenUris} = const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
useThreadgateHiddenReplyUris() threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => { const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByHiddenReplyCache = hiddenReplyUris.has(post.uri) const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const isPostHiddenByThreadgate = const rootPostUri = AppBskyFeedPost.isRecord(post.record)
!recentlyUnhiddenUris.has(post.uri) && ? post.record?.reply?.root?.uri || post.uri
!!threadgateRecord?.hiddenReplies?.includes(post.uri) : undefined
const isHidden = isPostHiddenByHiddenReplyCache || isPostHiddenByThreadgate
const isControlledByViewer = const isControlledByViewer =
isPostHiddenByHiddenReplyCache || rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
(threadgateRecord && return isControlledByViewer && isPostHiddenByThreadgate
new AtUri(threadgateRecord.post).host === currentAccount?.did)
if (!isControlledByViewer) return []
const alertSource =
threadgateRecord && isPostHiddenByThreadgate
? new AtUri(threadgateRecord.post).host
: isPostHiddenByHiddenReplyCache
? currentAccount?.did
: undefined
return isHidden && alertSource
? [ ? [
{ {
type: 'reply-hidden', type: 'reply-hidden',
source: {type: 'user', did: alertSource}, source: {type: 'user', did: currentAccount?.did},
priority: 6, priority: 6,
}, },
] ]
: [] : []
}, [ }, [post, currentAccount?.did, threadgateHiddenReplies])
post,
hiddenReplyUris,
recentlyUnhiddenUris,
threadgateRecord,
currentAccount?.did,
])
const onPressShowMore = React.useCallback(() => { const onPressShowMore = React.useCallback(() => {
setLimitLines(false) setLimitLines(false)

View File

@ -37,7 +37,7 @@ import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies' import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers' import {toShareUrl} from 'lib/strings/url-helpers'
@ -124,8 +124,6 @@ let PostDropdownBtn = ({
const hideReplyConfirmControl = useDialogControl() const hideReplyConfirmControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} = const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation() useToggleReplyVisibilityMutation()
const {uris: hiddenReplies, recentlyUnhiddenUris} =
useThreadgateHiddenReplyUris()
const postUri = post.uri const postUri = post.uri
const postCid = post.cid const postCid = post.cid
@ -147,10 +145,10 @@ let PostDropdownBtn = ({
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri) const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did const isAuthor = postAuthor.did === currentAccount?.did
const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
const isReplyHiddenByThreadgate = const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
hiddenReplies.has(postUri) || threadgateRecord,
(!recentlyUnhiddenUris.has(postUri) && })
threadgateRecord?.hiddenReplies?.includes(postUri)) const isReplyHiddenByThreadgate = threadgateHiddenReplies.has(postUri)
const {mutateAsync: toggleQuoteDetachment, isPending} = const {mutateAsync: toggleQuoteDetachment, isPending} =
useToggleQuoteDetachmentMutation() useToggleQuoteDetachmentMutation()