diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 69 | ||||
-rw-r--r-- | src/view/com/composer/threadgate/ThreadgateBtn.tsx | 51 | ||||
-rw-r--r-- | src/view/com/composer/useExternalLinkFetch.ts | 18 | ||||
-rw-r--r-- | src/view/com/post-thread/PostQuotes.tsx | 5 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThread.tsx | 65 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 34 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 69 | ||||
-rw-r--r-- | src/view/com/posts/FeedSlice.tsx | 4 | ||||
-rw-r--r-- | src/view/com/util/forms/PostDropdownBtn.tsx | 262 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 5 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/RepostButton.tsx | 29 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/RepostButton.web.tsx | 15 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/QuoteEmbed.tsx | 18 | ||||
-rw-r--r-- | src/view/screens/DebugMod.tsx | 1 |
14 files changed, 565 insertions, 80 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 0efbe70e6..eefd0affc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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} /> )} diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx index 6cf2eea2c..666473afd 100644 --- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx +++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx @@ -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} /> </> ) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts index 2938ea25a..317514437 100644 --- a/src/view/com/composer/useExternalLinkFetch.ts +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -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} } diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx index d573d27a1..f91a041d7 100644 --- a/src/view/com/post-thread/PostQuotes.tsx +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -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: { diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx index c64be8d67..bd778fd98 100644 --- a/src/view/com/post-thread/PostThread.tsx +++ b/src/view/com/post-thread/PostThread.tsx @@ -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 diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 26a5f2f03..da187f5d9 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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]}>·</Text> diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 0fef4c5a8..e90e8b885 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -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) { diff --git a/src/view/com/posts/FeedSlice.tsx b/src/view/com/posts/FeedSlice.tsx index 9676eff1f..0920026f6 100644 --- a/src/view/com/posts/FeedSlice.tsx +++ b/src/view/com/posts/FeedSlice.tsx @@ -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} /> ))} </> diff --git a/src/view/com/util/forms/PostDropdownBtn.tsx b/src/view/com/util/forms/PostDropdownBtn.tsx index 6c82ec8cc..b293b0dff 100644 --- a/src/view/com/util/forms/PostDropdownBtn.tsx +++ b/src/view/com/util/forms/PostDropdownBtn.tsx @@ -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> ) } diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index ad5863846..0cfa3fc4d 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -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 && ( diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index d49cda442..5994b7ef6 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -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> diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx index 17ab736ce..9a8776b9c 100644 --- a/src/view/com/util/post-ctrls/RepostButton.web.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.web.tsx @@ -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> diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx index 20c05b692..192aea708 100644 --- a/src/view/com/util/post-embeds/QuoteEmbed.tsx +++ b/src/view/com/util/post-embeds/QuoteEmbed.tsx @@ -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 } diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx index 7d0d2fb03..9c609348e 100644 --- a/src/view/screens/DebugMod.tsx +++ b/src/view/screens/DebugMod.tsx @@ -807,6 +807,7 @@ function MockPostFeedItem({ showReplyTo={false} reason={undefined} feedContext={''} + rootPost={post} /> ) } |