Detached QPs and hidden replies (#4878)

Co-authored-by: Hailey <me@haileyok.com>
This commit is contained in:
Eric Bailey 2024-08-21 21:20:45 -05:00 committed by GitHub
parent 56ab5e177f
commit 6616a6467e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 2584 additions and 622 deletions

View file

@ -58,9 +58,11 @@ import {
useLanguagePrefs,
useLanguagePrefsApi,
} from '#/state/preferences/languages'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {threadgateViewToAllowUISetting} from '#/state/queries/threadgate/util'
import {useUploadVideo} from '#/state/queries/video/video'
import {useAgent, useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
@ -81,9 +83,12 @@ import {State as VideoUploadState} from 'state/queries/video/video'
import {ComposerOpts} from 'state/shell/composer'
import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
import * as Prompt from '#/components/Prompt'
import {Text as NewText} from '#/components/Typography'
import {QuoteEmbed, QuoteX} from '../util/post-embeds/QuoteEmbed'
import {Text} from '../util/text/Text'
import * as Toast from '../util/Toast'
@ -182,10 +187,14 @@ export const ComposePost = observer(function ComposePost({
})
const [publishOnUpload, setPublishOnUpload] = useState(false)
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const {extLink, setExtLink} = useExternalLinkFetch({setQuote, setError})
const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
useState<ThreadgateAllowUISetting[]>(
threadgateViewToAllowUISetting(undefined),
)
const [postgate, setPostgate] = useState(createPostgateRecord({post: ''}))
const gallery = useMemo(
() => new GalleryModel(initImageUris),
@ -335,7 +344,8 @@ export const ComposePost = observer(function ComposePost({
quote,
extLink,
labels,
threadgate,
threadgate: threadgateAllowUISettings,
postgate,
onStateChange: setProcessingState,
langs: toPostLanguages(langPrefs.postLanguage),
})
@ -581,15 +591,40 @@ export const ComposePost = observer(function ComposePost({
</View>
)}
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
<FontAwesomeIcon
icon="exclamation"
style={{color: colors.red4}}
size={10}
/>
<View style={[a.px_lg, a.pb_sm]}>
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.gap_sm,
t.atoms.bg_contrast_25,
{
paddingRight: 48,
},
]}>
<CircleInfo fill={t.palette.negative_400} />
<NewText style={[a.flex_1, a.leading_snug, {paddingTop: 1}]}>
{error}
</NewText>
<Button
label={_(msg`Dismiss error`)}
size="tiny"
color="secondary"
variant="ghost"
shape="round"
style={[
a.absolute,
{
top: a.py_sm.paddingTop,
right: a.px_md.paddingRight,
},
]}
onPress={() => setError('')}>
<ButtonIcon icon={X} />
</Button>
</View>
<Text style={[s.red4, a.flex_1]}>{error}</Text>
</View>
)}
</Animated.View>
@ -680,8 +715,12 @@ export const ComposePost = observer(function ComposePost({
{replyTo ? null : (
<ThreadgateBtn
threadgate={threadgate}
onChange={setThreadgate}
postgate={postgate}
onChangePostgate={setPostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={
onChangeThreadgateAllowUISettings
}
style={bottomBarAnimatedStyle}
/>
)}

View file

@ -1,27 +1,33 @@
import React from 'react'
import {Keyboard, StyleProp, ViewStyle} from 'react-native'
import Animated, {AnimatedStyle} from 'react-native-reanimated'
import {AppBskyFeedPostgate} from '@atproto/api'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {isNative} from '#/platform/detection'
import {ThreadgateSetting} from '#/state/queries/threadgate'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
import {useAnalytics} from 'lib/analytics/analytics'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {ThreadgateEditorDialog} from '#/components/dialogs/ThreadgateEditor'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {PostInteractionSettingsControlledDialog} from '#/components/dialogs/PostInteractionSettingsDialog'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
export function ThreadgateBtn({
threadgate,
onChange,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
style,
}: {
threadgate: ThreadgateSetting[]
onChange: (v: ThreadgateSetting[]) => void
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
style?: StyleProp<AnimatedStyle<ViewStyle>>
}) {
const {track} = useAnalytics()
@ -38,13 +44,15 @@ export function ThreadgateBtn({
control.open()
}
const isEverybody = threadgate.length === 0
const isNobody = !!threadgate.find(gate => gate.type === 'nobody')
const label = isEverybody
? _(msg`Everybody can reply`)
: isNobody
? _(msg`Nobody can reply`)
: _(msg`Some people can reply`)
const anyoneCanReply =
threadgateAllowUISettings.length === 1 &&
threadgateAllowUISettings[0].type === 'everybody'
const anyoneCanQuote =
!postgate.embeddingRules || postgate.embeddingRules.length === 0
const anyoneCanInteract = anyoneCanReply && anyoneCanQuote
const label = anyoneCanInteract
? _(msg`Anybody can interact`)
: _(msg`Interaction limited`)
return (
<>
@ -59,16 +67,19 @@ export function ThreadgateBtn({
accessibilityHint={_(
msg`Opens a dialog to choose who can reply to this thread`,
)}>
<ButtonIcon
icon={isEverybody ? Earth : isNobody ? CircleBanSign : Group}
/>
<ButtonIcon icon={anyoneCanInteract ? Earth : Group} />
<ButtonText>{label}</ButtonText>
</Button>
</Animated.View>
<ThreadgateEditorDialog
<PostInteractionSettingsControlledDialog
control={control}
threadgate={threadgate}
onChange={onChange}
onSave={() => {
control.close()
}}
postgate={postgate}
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
/>
</>
)

View file

@ -1,4 +1,6 @@
import {useEffect, useState} from 'react'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {logger} from '#/logger'
import {useFetchDid} from '#/state/queries/handle'
@ -7,6 +9,7 @@ import {useAgent} from '#/state/session'
import * as apilib from 'lib/api/index'
import {POST_IMG_MAX} from 'lib/constants'
import {
EmbeddingDisabledError,
getFeedAsEmbed,
getListAsEmbed,
getPostAsQuote,
@ -28,9 +31,12 @@ import {ComposerOpts} from 'state/shell/composer'
export function useExternalLinkFetch({
setQuote,
setError,
}: {
setQuote: (opts: ComposerOpts['quote']) => void
setError: (err: string) => void
}) {
const {_} = useLingui()
const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>(
undefined,
)
@ -57,9 +63,13 @@ export function useExternalLinkFetch({
setExtLink(undefined)
},
err => {
logger.error('Failed to fetch post for quote embedding', {
message: err.toString(),
})
if (err instanceof EmbeddingDisabledError) {
setError(_(msg`This post's author has disabled quote posts.`))
} else {
logger.error('Failed to fetch post for quote embedding', {
message: err.toString(),
})
}
setExtLink(undefined)
},
)
@ -170,7 +180,7 @@ export function useExternalLinkFetch({
})
}
return cleanup
}, [extLink, setQuote, getPost, fetchDid, agent])
}, [_, extLink, setQuote, getPost, fetchDid, agent, setError])
return {extLink, setExtLink}
}

View file

@ -10,7 +10,6 @@ import {useLingui} from '@lingui/react'
import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
import {cleanError} from '#/lib/strings/errors'
import {logger} from '#/logger'
import {isWeb} from '#/platform/detection'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {usePostQuotesQuery} from '#/state/queries/post-quotes'
import {useResolveUriQuery} from '#/state/queries/resolve-uri'
@ -25,16 +24,14 @@ import {List} from '../util/List'
function renderItem({
item,
index,
}: {
item: {
post: AppBskyFeedDefs.PostView
moderation: ModerationDecision
record: AppBskyFeedPost.Record
}
index: number
}) {
return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} />
return <Post post={item.post} />
}
function keyExtractor(item: {

View file

@ -3,7 +3,12 @@ import {StyleSheet, useWindowDimensions, View} from 'react-native'
import {runOnJS} from 'react-native-reanimated'
import Animated from 'react-native-reanimated'
import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyFeedDefs} from '@atproto/api'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
@ -23,6 +28,7 @@ import {
usePostThreadQuery,
} from '#/state/queries/post-thread'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell'
import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender'
@ -113,6 +119,28 @@ export function PostThread({uri}: {uri: string | undefined}) {
)
const rootPost = thread?.type === 'post' ? thread.post : undefined
const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
const replyRef =
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 {data: threadgateRecord} = useThreadgateRecordQuery({
/**
* If the user is the OP and the root post has a threadgate, we should load
* the threadgate record. Otherwise, fallback to initialData, which is taken
* from the response from `getPostThread`.
*/
enabled: Boolean(isOP && rootPostUri),
postUri: rootPostUri,
initialData: rootPost?.threadgate?.record as
| AppBskyFeedThreadgate.Record
| undefined,
})
const moderationOpts = useModerationOpts()
const isNoPwi = React.useMemo(() => {
@ -167,6 +195,9 @@ export function PostThread({uri}: {uri: string | undefined}) {
const skeleton = React.useMemo(() => {
const threadViewPrefs = preferences?.threadViewPrefs
if (!threadViewPrefs || !thread) return null
const threadgateRecordHiddenReplies = new Set<string>(
threadgateRecord?.hiddenReplies || [],
)
return createThreadSkeleton(
sortThread(
@ -175,11 +206,13 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache,
currentDid,
justPostedUris,
threadgateRecordHiddenReplies,
),
!!currentDid,
currentDid,
treeView,
threadModerationCache,
hiddenRepliesState !== HiddenRepliesState.Hide,
threadgateRecordHiddenReplies,
)
}, [
thread,
@ -189,6 +222,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
threadModerationCache,
hiddenRepliesState,
justPostedUris,
threadgateRecord,
])
const error = React.useMemo(() => {
@ -425,6 +459,7 @@ export function PostThread({uri}: {uri: string | undefined}) {
<PostThreadItem
post={item.post}
record={item.record}
threadgateRecord={threadgateRecord ?? undefined}
moderation={threadModerationCache.get(item)}
treeView={treeView}
depth={item.ctx.depth}
@ -545,23 +580,25 @@ function isThreadBlocked(v: unknown): v is ThreadBlocked {
function createThreadSkeleton(
node: ThreadNode,
hasSession: boolean,
currentDid: string | undefined,
treeView: boolean,
modCache: ThreadModerationCache,
showHiddenReplies: boolean,
threadgateRecordHiddenReplies: Set<string>,
): ThreadSkeletonParts | null {
if (!node) return null
return {
parents: Array.from(flattenThreadParents(node, hasSession)),
parents: Array.from(flattenThreadParents(node, !!currentDid)),
highlightedPost: node,
replies: Array.from(
flattenThreadReplies(
node,
hasSession,
currentDid,
treeView,
modCache,
showHiddenReplies,
threadgateRecordHiddenReplies,
),
),
}
@ -594,14 +631,15 @@ enum HiddenReplyType {
function* flattenThreadReplies(
node: ThreadNode,
hasSession: boolean,
currentDid: string | undefined,
treeView: boolean,
modCache: ThreadModerationCache,
showHiddenReplies: boolean,
threadgateRecordHiddenReplies: Set<string>,
): Generator<YieldedItem, HiddenReplyType> {
if (node.type === 'post') {
// dont show pwi-opted-out posts to logged out users
if (!hasSession && hasPwiOptOut(node)) {
if (!currentDid && hasPwiOptOut(node)) {
return HiddenReplyType.None
}
@ -616,6 +654,16 @@ function* flattenThreadReplies(
return HiddenReplyType.Hidden
}
}
if (!showHiddenReplies) {
const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
node.post.uri,
)
const authorIsViewer = node.post.author.did === currentDid
if (hiddenByThreadgate && !authorIsViewer) {
return HiddenReplyType.Hidden
}
}
}
if (!node.ctx.isHighlightedPost) {
@ -627,10 +675,11 @@ function* flattenThreadReplies(
for (const reply of node.replies) {
let hiddenReply = yield* flattenThreadReplies(
reply,
hasSession,
currentDid,
treeView,
modCache,
showHiddenReplies,
threadgateRecordHiddenReplies,
)
if (hiddenReply > hiddenReplies) {
hiddenReplies = hiddenReply

View file

@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
ModerationDecision,
RichText as RichTextAPI,
@ -29,6 +30,7 @@ import {isWeb} from 'platform/detection'
import {useSession} from 'state/session'
import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn'
import {atoms as a} from '#/alf'
import {AppModerationCause} from '#/components/Pills'
import {RichText} from '#/components/RichText'
import {ContentHider} from '../../../components/moderation/ContentHider'
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
@ -61,6 +63,7 @@ export function PostThreadItem({
overrideBlur,
onPostReply,
hideTopBorder,
threadgateRecord,
}: {
post: AppBskyFeedDefs.PostView
record: AppBskyFeedPost.Record
@ -77,6 +80,7 @@ export function PostThreadItem({
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const postShadowed = usePostShadow(post)
const richText = useMemo(
@ -111,6 +115,7 @@ export function PostThreadItem({
overrideBlur={overrideBlur}
onPostReply={onPostReply}
hideTopBorder={hideTopBorder}
threadgateRecord={threadgateRecord}
/>
)
}
@ -154,6 +159,7 @@ let PostThreadItemLoaded = ({
overrideBlur,
onPostReply,
hideTopBorder,
threadgateRecord,
}: {
post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record
@ -171,6 +177,7 @@ let PostThreadItemLoaded = ({
overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
@ -199,6 +206,24 @@ let PostThreadItemLoaded = ({
return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by')
}, [post.uri, post.author])
const repostsTitle = _(msg`Reposts of this post`)
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByThreadgate = threadgateRecord?.hiddenReplies?.includes(
post.uri,
)
const isControlledByViewer =
threadgateRecord &&
new AtUri(threadgateRecord.post).host === currentAccount?.did
if (!isControlledByViewer) return []
return threadgateRecord && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: new AtUri(threadgateRecord.post).host},
priority: 6,
},
]
: []
}, [post, threadgateRecord, currentAccount?.did])
const quotesHref = React.useMemo(() => {
const urip = new AtUri(post.uri)
return makeProfileLink(post.author, 'post', urip.rkey, 'quotes')
@ -320,6 +345,7 @@ let PostThreadItemLoaded = ({
size="lg"
includeMute
style={[a.pt_2xs, a.pb_sm]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View
@ -420,6 +446,7 @@ let PostThreadItemLoaded = ({
onPressReply={onPressReply}
onPostReply={onPostReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
@ -540,6 +567,7 @@ let PostThreadItemLoaded = ({
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.pt_2xs, a.pb_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText?.text ? (
<View style={styles.postTextContainer}>
@ -571,6 +599,7 @@ let PostThreadItemLoaded = ({
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
threadgateRecord={threadgateRecord}
/>
</View>
</View>
@ -677,6 +706,7 @@ function ExpandedPostDetails({
const pal = usePalette('default')
const {_} = useLingui()
const openLink = useOpenLink()
const isRootPost = !('reply' in post.record)
const onTranslatePress = React.useCallback(() => {
openLink(translatorUrl)
@ -693,7 +723,9 @@ function ExpandedPostDetails({
s.mb10,
]}>
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
{isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} />
)}
{needsTranslation && (
<>
<Text style={[a.text_sm, pal.textLight]}>&middot;</Text>

View file

@ -4,6 +4,7 @@ import {
AppBskyActorDefs,
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
ModerationDecision,
RichText as RichTextAPI,
@ -21,6 +22,7 @@ import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useFeedFeedbackContext} from '#/state/feed-feedback'
import {useSession} from '#/state/session'
import {useComposerControls} from '#/state/shell/composer'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types'
import {MAX_POST_LINES} from 'lib/constants'
import {usePalette} from 'lib/hooks/usePalette'
@ -33,6 +35,7 @@ import {precacheProfile} from 'state/queries/profile'
import {atoms as a} from '#/alf'
import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost'
import {ContentHider} from '#/components/moderation/ContentHider'
import {AppModerationCause} from '#/components/Pills'
import {ProfileHoverCard} from '#/components/ProfileHoverCard'
import {RichText} from '#/components/RichText'
import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe'
@ -80,7 +83,11 @@ export function FeedItem({
hideTopBorder,
isParentBlocked,
isParentNotFound,
}: FeedItemProps & {post: AppBskyFeedDefs.PostView}): React.ReactNode {
rootPost,
}: FeedItemProps & {
post: AppBskyFeedDefs.PostView
rootPost: AppBskyFeedDefs.PostView
}): React.ReactNode {
const postShadowed = usePostShadow(post)
const richText = useMemo(
() =>
@ -112,6 +119,7 @@ export function FeedItem({
hideTopBorder={hideTopBorder}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
rootPost={rootPost}
/>
)
}
@ -133,9 +141,11 @@ let FeedItemInner = ({
hideTopBorder,
isParentBlocked,
isParentNotFound,
rootPost,
}: FeedItemProps & {
richText: RichTextAPI
post: Shadow<AppBskyFeedDefs.PostView>
rootPost: AppBskyFeedDefs.PostView
}): React.ReactNode => {
const queryClient = useQueryClient()
const {openComposer} = useComposerControls()
@ -217,6 +227,12 @@ let FeedItemInner = ({
AppBskyFeedDefs.isReasonRepost(reason) &&
reason.by.did === currentAccount?.did
const threadgateRecord = AppBskyFeedThreadgate.isRecord(
rootPost.threadgate?.record,
)
? rootPost.threadgate.record
: undefined
return (
<Link
testID={`feedItem-by-${post.author.handle}`}
@ -363,6 +379,8 @@ let FeedItemInner = ({
postEmbed={post.embed}
postAuthor={post.author}
onOpenEmbed={onOpenEmbed}
post={post}
threadgateRecord={threadgateRecord}
/>
<VideoDebug />
<PostCtrls
@ -372,6 +390,7 @@ let FeedItemInner = ({
onPressReply={onPressReply}
logContext="FeedItem"
feedContext={feedContext}
threadgateRecord={threadgateRecord}
/>
</View>
</View>
@ -381,23 +400,63 @@ let FeedItemInner = ({
FeedItemInner = memo(FeedItemInner)
let PostContent = ({
post,
moderation,
richText,
postEmbed,
postAuthor,
onOpenEmbed,
threadgateRecord,
}: {
moderation: ModerationDecision
richText: RichTextAPI
postEmbed: AppBskyFeedDefs.PostView['embed']
postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void
post: AppBskyFeedDefs.PostView
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const pal = usePalette('default')
const {_} = useLingui()
const {currentAccount} = useSession()
const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES,
)
const {uris: hiddenReplyUris, recentlyUnhiddenUris} =
useThreadgateHiddenReplyUris()
const additionalPostAlerts: AppModerationCause[] = React.useMemo(() => {
const isPostHiddenByHiddenReplyCache = hiddenReplyUris.has(post.uri)
const isPostHiddenByThreadgate =
!recentlyUnhiddenUris.has(post.uri) &&
!!threadgateRecord?.hiddenReplies?.includes(post.uri)
const isHidden = isPostHiddenByHiddenReplyCache || isPostHiddenByThreadgate
const isControlledByViewer =
isPostHiddenByHiddenReplyCache ||
(threadgateRecord &&
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',
source: {type: 'user', did: alertSource},
priority: 6,
},
]
: []
}, [
post,
hiddenReplyUris,
recentlyUnhiddenUris,
threadgateRecord,
currentAccount?.did,
])
const onPressShowMore = React.useCallback(() => {
setLimitLines(false)
@ -409,7 +468,11 @@ let PostContent = ({
modui={moderation.ui('contentList')}
ignoreMute
childContainerStyle={styles.contentHiderChild}>
<PostAlerts modui={moderation.ui('contentList')} style={[a.py_2xs]} />
<PostAlerts
modui={moderation.ui('contentList')}
style={[a.py_2xs]}
additionalCauses={additionalPostAlerts}
/>
{richText.text ? (
<View style={styles.postTextContainer}>
<RichText
@ -460,7 +523,7 @@ function ReplyToLabel({
if (blocked) {
label = <Trans context="description">Reply to a blocked post</Trans>
} else if (notFound) {
label = <Trans context="description">Reply to an unknown post</Trans>
label = <Trans context="description">Reply to a post</Trans>
} else if (profile != null) {
const isMe = profile.did === currentAccount?.did
if (isMe) {

View file

@ -37,6 +37,7 @@ let FeedSlice = ({
hideTopBorder={hideTopBorder}
isParentBlocked={slice.items[0].isParentBlocked}
isParentNotFound={slice.items[0].isParentNotFound}
rootPost={slice.items[0].post}
/>
<ViewFullThread uri={slice.items[0].uri} />
<FeedItem
@ -55,6 +56,7 @@ let FeedSlice = ({
isThreadChild={isThreadChildAt(slice.items, beforeLast)}
isParentBlocked={slice.items[beforeLast].isParentBlocked}
isParentNotFound={slice.items[beforeLast].isParentNotFound}
rootPost={slice.items[0].post}
/>
<FeedItem
key={slice.items[last]._reactKey}
@ -70,6 +72,7 @@ let FeedSlice = ({
isParentBlocked={slice.items[last].isParentBlocked}
isParentNotFound={slice.items[last].isParentNotFound}
isThreadLastChild
rootPost={slice.items[0].post}
/>
</>
)
@ -95,6 +98,7 @@ let FeedSlice = ({
isParentBlocked={slice.items[i].isParentBlocked}
isParentNotFound={slice.items[i].isParentNotFound}
hideTopBorder={hideTopBorder && i === 0}
rootPost={slice.items[0].post}
/>
))}
</>

View file

@ -1,5 +1,6 @@
import React, {memo} from 'react'
import {
Platform,
Pressable,
type PressableProps,
type StyleProp,
@ -9,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
@ -31,7 +33,11 @@ import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {useSession} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing'
import {toShareUrl} from 'lib/strings/url-helpers'
@ -40,6 +46,10 @@ import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {EmbedDialog} from '#/components/dialogs/Embed'
import {
PostInteractionSettingsDialog,
usePrefetchPostInteractionSettings,
} from '#/components/dialogs/PostInteractionSettingsDialog'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
import {BubbleQuestion_Stroke2_Corner0_Rounded as Translate} from '#/components/icons/Bubble'
@ -50,13 +60,16 @@ import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji'
import {Eye_Stroke2_Corner0_Rounded as Eye} from '#/components/icons/Eye'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
import {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
import {PaperPlane_Stroke2_Corner0_Rounded as Send} from '#/components/icons/PaperPlane'
import {SettingsGear2_Stroke2_Corner0_Rounded as Gear} from '#/components/icons/SettingsGear2'
import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as Unmute} from '#/components/icons/Speaker'
import {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {Loader} from '#/components/Loader'
import * as Menu from '#/components/Menu'
import * as Prompt from '#/components/Prompt'
import {ReportDialog, useReportDialogControl} from '#/components/ReportDialog'
@ -73,6 +86,7 @@ let PostDropdownBtn = ({
hitSlop,
size,
timestamp,
threadgateRecord,
}: {
testID: string
post: Shadow<AppBskyFeedDefs.PostView>
@ -83,6 +97,7 @@ let PostDropdownBtn = ({
hitSlop?: PressableProps['hitSlop']
size?: 'lg' | 'md' | 'sm'
timestamp: string
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const {hasSession, currentAccount} = useSession()
const theme = useTheme()
@ -104,17 +119,46 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl()
const quotePostDetachConfirmControl = useDialogControl()
const hideReplyConfirmControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation()
const {uris: hiddenReplies, recentlyUnhiddenUris} =
useThreadgateHiddenReplyUris()
const postUri = post.uri
const postCid = post.cid
const postAuthor = post.author
const quoteEmbed = React.useMemo(() => {
if (!currentAccount || !post.embed) return
return getMaybeDetachedQuoteEmbed({
viewerDid: currentAccount.did,
post,
})
}, [post, currentAccount])
const rootUri = record.reply?.root?.uri || postUri
const isReply = Boolean(record.reply)
const [isThreadMuted, muteThread, unmuteThread] = useThreadMuteMutationQueue(
post,
rootUri,
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
const isRootPostAuthor = new AtUri(rootUri).host === currentAccount?.did
const isReplyHiddenByThreadgate =
hiddenReplies.has(postUri) ||
(!recentlyUnhiddenUris.has(postUri) &&
threadgateRecord?.hiddenReplies?.includes(postUri))
const {mutateAsync: toggleQuoteDetachment, isPending} =
useToggleQuoteDetachmentMutation()
const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
postUri: post.uri,
rootPostUri: rootUri,
})
const href = React.useMemo(() => {
const urip = new AtUri(postUri)
@ -242,7 +286,65 @@ let PostDropdownBtn = ({
[navigation, postUri],
)
const onToggleQuotePostAttachment = React.useCallback(async () => {
if (!quoteEmbed) return
const action = quoteEmbed.isDetached ? 'reattach' : 'detach'
const isDetach = action === 'detach'
try {
await toggleQuoteDetachment({
post,
quoteUri: quoteEmbed.uri,
action: quoteEmbed.isDetached ? 'reattach' : 'detach',
})
Toast.show(
isDetach
? _(msg`Quote post was successfully detached`)
: _(msg`Quote post was re-attached`),
)
} catch (e: any) {
Toast.show(_(msg`Updating quote attachment failed`))
logger.error(`Failed to ${action} quote`, {safeMessage: e.message})
}
}, [_, quoteEmbed, post, toggleQuoteDetachment])
const canHidePostForMe = !isAuthor && !isPostHidden
const canEmbed = isWeb && gtMobile && !hideInPWI
const canHideReplyForEveryone =
!isAuthor && isRootPostAuthor && !isPostHidden && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
const onToggleReplyVisibility = React.useCallback(async () => {
// TODO no threadgate?
if (!canHideReplyForEveryone) return
const action = isReplyHiddenByThreadgate ? 'show' : 'hide'
const isHide = action === 'hide'
try {
await toggleReplyVisibility({
postUri: rootUri,
replyUri: postUri,
action,
})
Toast.show(
isHide
? _(msg`Reply was successfully hidden`)
: _(msg`Reply visibility updated`),
)
} catch (e: any) {
Toast.show(_(msg`Updating reply visibility failed`))
logger.error(`Failed to ${action} reply`, {safeMessage: e.message})
}
}, [
_,
isReplyHiddenByThreadgate,
rootUri,
postUri,
canHideReplyForEveryone,
toggleReplyVisibility,
])
return (
<EventStopper onKeyDown={false}>
@ -383,20 +485,92 @@ let PostDropdownBtn = ({
<Menu.ItemText>{_(msg`Mute words & tags`)}</Menu.ItemText>
<Menu.ItemIcon icon={Filter} position="right" />
</Menu.Item>
{!isAuthor && !isPostHidden && (
<Menu.Item
testID="postDropdownHideBtn"
label={_(msg`Hide post`)}
onPress={hidePromptControl.open}>
<Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
)}
</Menu.Group>
</>
)}
{hasSession &&
(canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
<>
<Menu.Divider />
<Menu.Group>
{canHidePostForMe && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
onPress={hidePromptControl.open}>
<Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item>
)}
{canHideReplyForEveryone && (
<Menu.Item
testID="postDropdownHideBtn"
label={
isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)
}
onPress={
isReplyHiddenByThreadgate
? onToggleReplyVisibility
: () => hideReplyConfirmControl.open()
}>
<Menu.ItemText>
{isReplyHiddenByThreadgate
? _(msg`Show reply for everyone`)
: _(msg`Hide reply for everyone`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={isReplyHiddenByThreadgate ? Eye : EyeSlash}
position="right"
/>
</Menu.Item>
)}
{canDetachQuote && (
<Menu.Item
disabled={isPending}
testID="postDropdownHideBtn"
label={
quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)
}
onPress={
quoteEmbed.isDetached
? onToggleQuotePostAttachment
: () => quotePostDetachConfirmControl.open()
}>
<Menu.ItemText>
{quoteEmbed.isDetached
? _(msg`Re-attach quote`)
: _(msg`Detach quote`)}
</Menu.ItemText>
<Menu.ItemIcon
icon={
isPending
? Loader
: quoteEmbed.isDetached
? Eye
: EyeSlash
}
position="right"
/>
</Menu.Item>
)}
</Menu.Group>
</>
)}
{hasSession && (
<>
<Menu.Divider />
@ -412,13 +586,34 @@ let PostDropdownBtn = ({
)}
{isAuthor && (
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
<>
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
onPress={postInteractionSettingsDialogControl.open}
{...(isAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</>
@ -439,8 +634,10 @@ let PostDropdownBtn = ({
<Prompt.Basic
control={hidePromptControl}
title={_(msg`Hide this post?`)}
description={_(msg`This post will be hidden from feeds.`)}
title={isReply ? _(msg`Hide this reply?`) : _(msg`Hide this post?`)}
description={_(
msg`This post will be hidden from feeds and threads. This cannot be undone.`,
)}
onConfirm={onHidePost}
confirmButtonCta={_(msg`Hide`)}
/>
@ -479,6 +676,33 @@ let PostDropdownBtn = ({
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
<PostInteractionSettingsDialog
control={postInteractionSettingsDialogControl}
postUri={post.uri}
rootPostUri={rootUri}
initialThreadgateView={post.threadgate}
/>
<Prompt.Basic
control={quotePostDetachConfirmControl}
title={_(msg`Detach quote post?`)}
description={_(
msg`This will remove your post from this quote post for all users, and replace it with a placeholder.`,
)}
onConfirm={onToggleQuotePostAttachment}
confirmButtonCta={_(msg`Yes, detach`)}
/>
<Prompt.Basic
control={hideReplyConfirmControl}
title={_(msg`Hide this reply?`)}
description={_(
msg`This reply will be sorted into a hidden section at the bottom of your thread and will mute notifications for subsequent replies - both for yourself and others.`,
)}
onConfirm={onToggleReplyVisibility}
confirmButtonCta={_(msg`Yes, hide`)}
/>
</EventStopper>
)
}

View file

@ -10,6 +10,7 @@ import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
} from '@atproto/api'
@ -60,6 +61,7 @@ let PostCtrls = ({
onPressReply,
onPostReply,
logContext,
threadgateRecord,
}: {
big?: boolean
post: Shadow<AppBskyFeedDefs.PostView>
@ -70,6 +72,7 @@ let PostCtrls = ({
onPressReply: () => void
onPostReply?: (postUri: string | undefined) => void
logContext: 'FeedItem' | 'PostThreadItem' | 'Post'
threadgateRecord?: AppBskyFeedThreadgate.Record
}): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
@ -256,6 +259,7 @@ let PostCtrls = ({
onRepost={onRepost}
onQuote={onQuote}
big={big}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/>
</View>
<View style={big ? a.align_center : [a.flex_1, a.align_start]}>
@ -344,6 +348,7 @@ let PostCtrls = ({
style={{padding: 5}}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt}
threadgateRecord={threadgateRecord}
/>
</View>
{gate('debug_show_feedcontext') && feedContext && (

View file

@ -20,6 +20,7 @@ interface Props {
onRepost: () => void
onQuote: () => void
big?: boolean
embeddingDisabled: boolean
}
let RepostButton = ({
@ -28,6 +29,7 @@ let RepostButton = ({
onRepost,
onQuote,
big,
embeddingDisabled,
}: Props): React.ReactNode => {
const t = useTheme()
const {_} = useLingui()
@ -111,9 +113,14 @@ let RepostButton = ({
</Text>
</Button>
<Button
disabled={embeddingDisabled}
testID="quoteBtn"
style={[a.justify_start, a.px_md]}
label={_(msg`Quote post`)}
label={
embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)
}
onPress={() => {
playHaptic()
dialogControl.close(() => {
@ -123,9 +130,23 @@ let RepostButton = ({
size="large"
variant="ghost"
color="primary">
<Quote size="lg" fill={t.palette.primary_500} />
<Text style={[a.font_bold, a.text_xl]}>
{_(msg`Quote post`)}
<Quote
size="lg"
fill={
embeddingDisabled
? t.atoms.text_contrast_low.color
: t.palette.primary_500
}
/>
<Text
style={[
a.font_bold,
a.text_xl,
embeddingDisabled && t.atoms.text_contrast_low,
]}>
{embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)}
</Text>
</Button>
</View>

View file

@ -20,6 +20,7 @@ interface Props {
onRepost: () => void
onQuote: () => void
big?: boolean
embeddingDisabled: boolean
}
export const RepostButton = ({
@ -28,6 +29,7 @@ export const RepostButton = ({
onRepost,
onQuote,
big,
embeddingDisabled,
}: Props) => {
const t = useTheme()
const {_} = useLingui()
@ -76,10 +78,19 @@ export const RepostButton = ({
<Menu.ItemIcon icon={Repost} position="right" />
</Menu.Item>
<Menu.Item
label={_(msg`Quote post`)}
disabled={embeddingDisabled}
label={
embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)
}
testID="repostDropdownQuoteBtn"
onPress={onQuote}>
<Menu.ItemText>{_(msg`Quote post`)}</Menu.ItemText>
<Menu.ItemText>
{embeddingDisabled
? _(msg`Quote posts disabled`)
: _(msg`Quote post`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Quote} position="right" />
</Menu.Item>
</Menu.Outer>

View file

@ -26,6 +26,7 @@ import {useQueryClient} from '@tanstack/react-query'
import {HITSLOP_20} from '#/lib/constants'
import {s} from '#/lib/styles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useSession} from '#/state/session'
import {usePalette} from 'lib/hooks/usePalette'
import {InfoCircleIcon} from 'lib/icons'
import {makeProfileLink} from 'lib/routes/links'
@ -52,6 +53,7 @@ export function MaybeQuoteEmbed({
allowNestedQuotes?: boolean
}) {
const pal = usePalette('default')
const {currentAccount} = useSession()
if (
AppBskyEmbedRecord.isViewRecord(embed.record) &&
AppBskyFeedPost.isRecord(embed.record.value) &&
@ -84,6 +86,22 @@ export function MaybeQuoteEmbed({
</Text>
</View>
)
} else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
const isViewerOwner = currentAccount?.did
? embed.record.uri.includes(currentAccount.did)
: false
return (
<View style={[styles.errorContainer, pal.borderDark]}>
<InfoCircleIcon size={18} style={pal.text} />
<Text type="lg" style={pal.text}>
{isViewerOwner ? (
<Trans>Removed by you</Trans>
) : (
<Trans>Removed by author</Trans>
)}
</Text>
</View>
)
}
return null
}

View file

@ -807,6 +807,7 @@ function MockPostFeedItem({
showReplyTo={false}
reason={undefined}
feedContext={''}
rootPost={post}
/>
)
}