diff --git a/assets/icons/eye_stroke2_corner0_rounded.svg b/assets/icons/eye_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..035daa6e
--- /dev/null
+++ b/assets/icons/eye_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 61064804..8cf7e2be 100644
--- a/package.json
+++ b/package.json
@@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
},
"dependencies": {
- "@atproto/api": "^0.13.2",
+ "@atproto/api": "0.13.2",
"@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
diff --git a/src/App.native.tsx b/src/App.native.tsx
index bce439a7..69c7629b 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -50,6 +50,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
@@ -122,21 +123,23 @@ function InnerApp() {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index df6fbf24..9ec79253 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -39,6 +39,7 @@ import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
+import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web'
@@ -105,19 +106,21 @@ function InnerApp() {
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx
index 2fff9993..742a1166 100644
--- a/src/components/Pills.tsx
+++ b/src/components/Pills.tsx
@@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography'
+export type AppModerationCause =
+ | ModerationCause
+ | {
+ type: 'reply-hidden'
+ source: {type: 'user'; did: string}
+ priority: 6
+ downgraded?: boolean
+ }
+
export type CommonProps = {
size?: 'sm' | 'lg'
}
@@ -40,7 +49,7 @@ export function Row({
}
export type LabelProps = {
- cause: ModerationCause
+ cause: AppModerationCause
disableDetailsDialog?: boolean
noBg?: boolean
} & CommonProps
diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx
index 1ffb4da3..ab6ef829 100644
--- a/src/components/WhoCanReply.tsx
+++ b/src/components/WhoCanReply.tsx
@@ -1,39 +1,34 @@
import React from 'react'
-import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
+import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import {
AppBskyFeedDefs,
- AppBskyFeedGetPostThread,
+ AppBskyFeedPost,
AppBskyGraphDefs,
AtUri,
- BskyAgent,
} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
-import {useQueryClient} from '@tanstack/react-query'
-import {createThreadgate} from '#/lib/api'
-import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links'
-import {logger} from '#/logger'
import {isNative} from '#/platform/detection'
-import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {
- ThreadgateSetting,
- threadgateViewToSettings,
+ ThreadgateAllowUISetting,
+ threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate'
-import {useAgent} from '#/state/session'
-import * as Toast from 'view/com/util/Toast'
import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {useDialogControl} from '#/components/Dialog'
+import {
+ PostInteractionSettingsDialog,
+ usePrefetchPostInteractionSettings,
+} from '#/components/dialogs/PostInteractionSettingsDialog'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
+import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography'
-import {TextLink} from '../view/com/util/Link'
-import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor'
import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps {
@@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme()
const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl()
- const agent = useAgent()
- const queryClient = useQueryClient()
- const settings = React.useMemo(
- () => threadgateViewToSettings(post.threadgate),
- [post],
- )
- const isRootPost = !('reply' in post.record)
+ /*
+ * `WhoCanReply` is only used for root posts atm, in case this changes
+ * unexpectedly, we should check to make sure it's for sure the root URI.
+ */
+ const rootUri =
+ AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root
+ ? post.record.reply.root.uri
+ : post.uri
+ const settings = React.useMemo(() => {
+ return threadgateViewToAllowUISetting(post.threadgate)
+ }, [post.threadgate])
- if (!isRootPost) {
- return null
- }
- if (!settings.length && !isThreadAuthor) {
- return null
- }
+ const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
+ postUri: post.uri,
+ rootPostUri: rootUri,
+ })
- const isEverybody = settings.length === 0
- const isNobody = !!settings.find(gate => gate.type === 'nobody')
- const description = isEverybody
+ const anyoneCanReply =
+ settings.length === 1 && settings[0].type === 'everybody'
+ const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
+ const description = anyoneCanReply
? _(msg`Everybody can reply`)
- : isNobody
+ : noOneCanReply
? _(msg`Replies disabled`)
: _(msg`Some people can reply`)
- const onPressEdit = () => {
+ const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss()
}
@@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
}
}
- const onEditConfirm = async (newSettings: ThreadgateSetting[]) => {
- if (JSON.stringify(settings) === JSON.stringify(newSettings)) {
- return
- }
- try {
- if (newSettings.length) {
- await createThreadgate(agent, post.uri, newSettings)
- } else {
- await agent.api.com.atproto.repo.deleteRecord({
- repo: agent.session!.did,
- collection: 'app.bsky.feed.threadgate',
- rkey: new AtUri(post.uri).rkey,
- })
- }
- await whenAppViewReady(agent, post.uri, res => {
- const thread = res.data.thread
- if (AppBskyFeedDefs.isThreadViewPost(thread)) {
- const fetchedSettings = threadgateViewToSettings(
- thread.post.threadgate,
- )
- return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings)
- }
- return false
- })
- Toast.show(_(msg`Thread settings updated`))
- queryClient.invalidateQueries({
- queryKey: [POST_THREAD_RQKEY_ROOT],
- })
- } catch (err) {
- Toast.show(
- _(
- msg`There was an issue. Please check your internet connection and try again.`,
- ),
- 'xmark',
- )
- logger.error('Failed to edit threadgate', {message: err})
- }
- }
-
return (
<>
-
- {isThreadAuthor && (
-
+ ) : (
+
)}
>
@@ -174,7 +148,7 @@ function Icon({
}: {
color: string
width?: number
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
}) {
const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody')
@@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control,
post,
settings,
+ embeddingDisabled,
}: {
control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
-}) {
- return (
-
-
-
-
- )
-}
-
-function WhoCanReplyDialogInner({
- post,
- settings,
-}: {
- post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
+ embeddingDisabled: boolean
}) {
const {_} = useLingui()
return (
-
-
-
- Who can reply?
-
-
-
-
+
+
+
+
+
+ Who can interact with this post?
+
+
+
+
+
)
}
function Rules({
post,
settings,
+ embeddingDisabled,
}: {
post: AppBskyFeedDefs.PostView
- settings: ThreadgateSetting[]
+ settings: ThreadgateAllowUISetting[]
+ embeddingDisabled: boolean
}) {
const t = useTheme()
+
return (
-
- {!settings.length ? (
- Everybody can reply
- ) : settings[0].type === 'nobody' ? (
- Replies to this thread are disabled
- ) : (
-
- Only{' '}
- {settings.map((rule, i) => (
- <>
-
-
- >
- ))}{' '}
- can reply
-
+ <>
+
+ {settings[0].type === 'everybody' ? (
+ Everybody can reply to this post.
+ ) : settings[0].type === 'nobody' ? (
+ Replies to this post are disabled.
+ ) : (
+
+ Only{' '}
+ {settings.map((rule, i) => (
+
+
+
+
+ ))}{' '}
+ can reply.
+
+ )}{' '}
+
+ {embeddingDisabled && (
+
+ No one but the author can quote this post.
+
)}
-
+ >
)
}
@@ -267,11 +246,10 @@ function Rule({
post,
lists,
}: {
- rule: ThreadgateSetting
+ rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) {
- const t = useTheme()
if (rule.type === 'mention') {
return mentioned users
}
@@ -279,12 +257,12 @@ function Rule({
return (
users followed by{' '}
-
+
+ @{post.author.handle}
+
)
}
@@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri)
return (
- {' '}
+
+ {list.name}
+ {' '}
members
)
@@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
}
return <>, >
}
-
-async function whenAppViewReady(
- agent: BskyAgent,
- uri: string,
- fn: (res: AppBskyFeedGetPostThread.Response) => boolean,
-) {
- await until(
- 5, // 5 tries
- 1e3, // 1s delay between tries
- fn,
- () =>
- agent.app.bsky.feed.getPostThread({
- uri,
- depth: 0,
- }),
- )
-}
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx
new file mode 100644
index 00000000..a326602b
--- /dev/null
+++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx
@@ -0,0 +1,538 @@
+import React from 'react'
+import {StyleProp, View, ViewStyle} from 'react-native'
+import {AppBskyFeedDefs, AppBskyFeedPostgate, AtUri} from '@atproto/api'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {useQueryClient} from '@tanstack/react-query'
+import isEqual from 'lodash.isequal'
+
+import {logger} from '#/logger'
+import {STALE} from '#/state/queries'
+import {useMyListsQuery} from '#/state/queries/my-lists'
+import {
+ createPostgateQueryKey,
+ getPostgateRecord,
+ usePostgateQuery,
+ useWritePostgateMutation,
+} from '#/state/queries/postgate'
+import {
+ createPostgateRecord,
+ embeddingRules,
+} from '#/state/queries/postgate/util'
+import {
+ createThreadgateViewQueryKey,
+ getThreadgateView,
+ ThreadgateAllowUISetting,
+ threadgateViewToAllowUISetting,
+ useSetThreadgateAllowMutation,
+ useThreadgateViewQuery,
+} from '#/state/queries/threadgate'
+import {useAgent, useSession} from '#/state/session'
+import * as Toast from '#/view/com/util/Toast'
+import {atoms as a, useTheme} from '#/alf'
+import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import * as Dialog from '#/components/Dialog'
+import {Divider} from '#/components/Divider'
+import * as Toggle from '#/components/forms/Toggle'
+import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {Loader} from '#/components/Loader'
+import {Text} from '#/components/Typography'
+
+export type PostInteractionSettingsFormProps = {
+ onSave: () => void
+ isSaving?: boolean
+
+ postgate: AppBskyFeedPostgate.Record
+ onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
+
+ threadgateAllowUISettings: ThreadgateAllowUISetting[]
+ onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
+
+ replySettingsDisabled?: boolean
+}
+
+export function PostInteractionSettingsControlledDialog({
+ control,
+ ...rest
+}: PostInteractionSettingsFormProps & {
+ control: Dialog.DialogControlProps
+}) {
+ const {_} = useLingui()
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export type PostInteractionSettingsDialogProps = {
+ control: Dialog.DialogControlProps
+ /**
+ * URI of the post to edit the interaction settings for. Could be a root post
+ * or could be a reply.
+ */
+ postUri: string
+ /**
+ * The URI of the root post in the thread. Used to determine if the viewer
+ * owns the threadgate record and can therefore edit it.
+ */
+ rootPostUri: string
+ /**
+ * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we
+ * happen to have one before opening the settings dialog.
+ */
+ initialThreadgateView?: AppBskyFeedDefs.ThreadgateView
+}
+
+export function PostInteractionSettingsDialog(
+ props: PostInteractionSettingsDialogProps,
+) {
+ return (
+
+
+
+
+ )
+}
+
+export function PostInteractionSettingsDialogControlledInner(
+ props: PostInteractionSettingsDialogProps,
+) {
+ const {_} = useLingui()
+ const {currentAccount} = useSession()
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} =
+ useThreadgateViewQuery({postUri: props.rootPostUri})
+ const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({
+ postUri: props.postUri,
+ })
+
+ const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
+ const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation()
+
+ const [editedPostgate, setEditedPostgate] =
+ React.useState()
+ const [editedAllowUISettings, setEditedAllowUISettings] =
+ React.useState()
+
+ const isLoading = isLoadingThreadgate || isLoadingPostgate
+ const threadgateView = threadgateViewLoaded || props.initialThreadgateView
+ const isThreadgateOwnedByViewer = React.useMemo(() => {
+ return currentAccount?.did === new AtUri(props.rootPostUri).host
+ }, [props.rootPostUri, currentAccount?.did])
+
+ const postgateValue = React.useMemo(() => {
+ return (
+ editedPostgate || postgate || createPostgateRecord({post: props.postUri})
+ )
+ }, [postgate, editedPostgate, props.postUri])
+ const allowUIValue = React.useMemo(() => {
+ return (
+ editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView)
+ )
+ }, [threadgateView, editedAllowUISettings])
+
+ const onSave = React.useCallback(async () => {
+ if (!editedPostgate && !editedAllowUISettings) {
+ props.control.close()
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const requests = []
+
+ if (editedPostgate) {
+ requests.push(
+ writePostgateRecord({
+ postUri: props.postUri,
+ postgate: editedPostgate,
+ }),
+ )
+ }
+
+ if (editedAllowUISettings && isThreadgateOwnedByViewer) {
+ requests.push(
+ setThreadgateAllow({
+ postUri: props.rootPostUri,
+ allow: editedAllowUISettings,
+ }),
+ )
+ }
+
+ await Promise.all(requests)
+
+ props.control.close()
+ } catch (e: any) {
+ logger.error(`Failed to save post interaction settings`, {
+ context: 'PostInteractionSettingsDialogControlledInner',
+ safeMessage: e.message,
+ })
+ Toast.show(
+ _(
+ msg`There was an issue. Please check your internet connection and try again.`,
+ ),
+ 'xmark',
+ )
+ } finally {
+ setIsSaving(false)
+ }
+ }, [
+ _,
+ props.postUri,
+ props.rootPostUri,
+ props.control,
+ editedPostgate,
+ editedAllowUISettings,
+ setIsSaving,
+ writePostgateRecord,
+ setThreadgateAllow,
+ isThreadgateOwnedByViewer,
+ ])
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ )
+}
+
+export function PostInteractionSettingsForm({
+ onSave,
+ isSaving,
+ postgate,
+ onChangePostgate,
+ threadgateAllowUISettings,
+ onChangeThreadgateAllowUISettings,
+ replySettingsDisabled,
+}: PostInteractionSettingsFormProps) {
+ const t = useTheme()
+ const {_} = useLingui()
+ const control = Dialog.useDialogContext()
+ const {data: lists} = useMyListsQuery('curate')
+ const [quotesEnabled, setQuotesEnabled] = React.useState(
+ !(
+ postgate.embeddingRules &&
+ postgate.embeddingRules.find(
+ v => v.$type === embeddingRules.disableRule.$type,
+ )
+ ),
+ )
+
+ const onPressAudience = (setting: ThreadgateAllowUISetting) => {
+ // remove boolean values
+ let newSelected: ThreadgateAllowUISetting[] =
+ threadgateAllowUISettings.filter(
+ v => v.type !== 'nobody' && v.type !== 'everybody',
+ )
+ // toggle
+ const i = newSelected.findIndex(v => isEqual(v, setting))
+ if (i === -1) {
+ newSelected.push(setting)
+ } else {
+ newSelected.splice(i, 1)
+ }
+
+ onChangeThreadgateAllowUISettings(newSelected)
+ }
+
+ const onChangeQuotesEnabled = React.useCallback(
+ (enabled: boolean) => {
+ setQuotesEnabled(enabled)
+ onChangePostgate(
+ createPostgateRecord({
+ ...postgate,
+ embeddingRules: enabled ? [] : [embeddingRules.disableRule],
+ }),
+ )
+ },
+ [setQuotesEnabled, postgate, onChangePostgate],
+ )
+
+ const noOneCanReply = !!threadgateAllowUISettings.find(
+ v => v.type === 'nobody',
+ )
+
+ return (
+
+
+
+ Post interaction settings
+
+
+
+
+ Customize who can interact with this post.
+
+
+
+
+
+
+ Quote settings
+
+
+
+
+ {quotesEnabled ? (
+ Quote posts enabled
+ ) : (
+ Quote posts disabled
+ )}
+
+
+
+
+
+
+
+ {replySettingsDisabled && (
+
+
+
+
+ Reply settings are chosen by the author of the thread
+
+
+
+ )}
+
+
+
+ Reply settings
+
+
+
+ Allow replies from:
+
+
+
+ v.type === 'everybody')
+ }
+ onPress={() =>
+ onChangeThreadgateAllowUISettings([{type: 'everybody'}])
+ }
+ style={{flex: 1}}
+ disabled={replySettingsDisabled}
+ />
+
+ onChangeThreadgateAllowUISettings([{type: 'nobody'}])
+ }
+ style={{flex: 1}}
+ disabled={replySettingsDisabled}
+ />
+
+
+ {!noOneCanReply && (
+ <>
+
+ Or combine these options:
+
+
+
+ v.type === 'mention',
+ )
+ }
+ onPress={() => onPressAudience({type: 'mention'})}
+ disabled={replySettingsDisabled}
+ />
+ v.type === 'following',
+ )
+ }
+ onPress={() => onPressAudience({type: 'following'})}
+ disabled={replySettingsDisabled}
+ />
+ {lists && lists.length > 0
+ ? lists.map(list => (
+ v.type === 'list' && v.list === list.uri,
+ )
+ }
+ onPress={() =>
+ onPressAudience({type: 'list', list: list.uri})
+ }
+ disabled={replySettingsDisabled}
+ />
+ ))
+ : // No loading states to avoid jumps for the common case (no lists)
+ null}
+
+ >
+ )}
+
+
+
+
+
+
+ )
+}
+
+function Selectable({
+ label,
+ isSelected,
+ onPress,
+ style,
+ disabled,
+}: {
+ label: string
+ isSelected: boolean
+ onPress: () => void
+ style?: StyleProp
+ disabled?: boolean
+}) {
+ const t = useTheme()
+ return (
+
+ )
+}
+
+export function usePrefetchPostInteractionSettings({
+ postUri,
+ rootPostUri,
+}: {
+ postUri: string
+ rootPostUri: string
+}) {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+
+ return React.useCallback(async () => {
+ try {
+ await Promise.all([
+ queryClient.prefetchQuery({
+ queryKey: createPostgateQueryKey(postUri),
+ queryFn: () => getPostgateRecord({agent, postUri}),
+ staleTime: STALE.SECONDS.THIRTY,
+ }),
+ queryClient.prefetchQuery({
+ queryKey: createThreadgateViewQueryKey(rootPostUri),
+ queryFn: () => getThreadgateView({agent, postUri: rootPostUri}),
+ staleTime: STALE.SECONDS.THIRTY,
+ }),
+ ])
+ } catch (e: any) {
+ logger.error(`Failed to prefetch post interaction settings`, {
+ safeMessage: e.message,
+ })
+ }
+ }, [queryClient, agent, postUri, rootPostUri])
+}
diff --git a/src/components/dialogs/ThreadgateEditor.tsx b/src/components/dialogs/ThreadgateEditor.tsx
deleted file mode 100644
index 90483b3a..00000000
--- a/src/components/dialogs/ThreadgateEditor.tsx
+++ /dev/null
@@ -1,217 +0,0 @@
-import React from 'react'
-import {StyleProp, View, ViewStyle} from 'react-native'
-import {msg, Trans} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import isEqual from 'lodash.isequal'
-
-import {useMyListsQuery} from '#/state/queries/my-lists'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
-import {atoms as a, useTheme} from '#/alf'
-import {Button, ButtonText} from '#/components/Button'
-import * as Dialog from '#/components/Dialog'
-import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
-import {Text} from '#/components/Typography'
-
-interface ThreadgateEditorDialogProps {
- control: Dialog.DialogControlProps
- threadgate: ThreadgateSetting[]
- onChange?: (v: ThreadgateSetting[]) => void
- onConfirm?: (v: ThreadgateSetting[]) => void
-}
-
-export function ThreadgateEditorDialog({
- control,
- threadgate,
- onChange,
- onConfirm,
-}: ThreadgateEditorDialogProps) {
- return (
-
-
-
-
- )
-}
-
-function DialogContent({
- seedThreadgate,
- onChange,
- onConfirm,
-}: {
- seedThreadgate: ThreadgateSetting[]
- onChange?: (v: ThreadgateSetting[]) => void
- onConfirm?: (v: ThreadgateSetting[]) => void
-}) {
- const {_} = useLingui()
- const control = Dialog.useDialogContext()
- const {data: lists} = useMyListsQuery('curate')
- const [draft, setDraft] = React.useState(seedThreadgate)
-
- const [prevSeedThreadgate, setPrevSeedThreadgate] =
- React.useState(seedThreadgate)
- if (seedThreadgate !== prevSeedThreadgate) {
- // New data flowed from above (e.g. due to update coming through).
- setPrevSeedThreadgate(seedThreadgate)
- setDraft(seedThreadgate) // Reset draft.
- }
-
- function updateThreadgate(nextThreadgate: ThreadgateSetting[]) {
- setDraft(nextThreadgate)
- onChange?.(nextThreadgate)
- }
-
- const onPressEverybody = () => {
- updateThreadgate([])
- }
-
- const onPressNobody = () => {
- updateThreadgate([{type: 'nobody'}])
- }
-
- const onPressAudience = (setting: ThreadgateSetting) => {
- // remove nobody
- let newSelected: ThreadgateSetting[] = draft.filter(
- v => v.type !== 'nobody',
- )
- // toggle
- const i = newSelected.findIndex(v => isEqual(v, setting))
- if (i === -1) {
- newSelected.push(setting)
- } else {
- newSelected.splice(i, 1)
- }
- updateThreadgate(newSelected)
- }
-
- const doneLabel = onConfirm ? _(msg`Save`) : _(msg`Done`)
- return (
-
-
-
- Choose who can reply
-
-
- Either choose "Everybody" or "Nobody"
-
-
-
- v.type === 'nobody')}
- onPress={onPressNobody}
- style={{flex: 1}}
- />
-
-
- Or combine these options:
-
-
- v.type === 'mention')}
- onPress={() => onPressAudience({type: 'mention'})}
- />
- v.type === 'following')}
- onPress={() => onPressAudience({type: 'following'})}
- />
- {lists && lists.length > 0
- ? lists.map(list => (
- v.type === 'list' && v.list === list.uri)
- }
- onPress={() =>
- onPressAudience({type: 'list', list: list.uri})
- }
- />
- ))
- : // No loading states to avoid jumps for the common case (no lists)
- null}
-
-
-
-
-
- )
-}
-
-function Selectable({
- label,
- isSelected,
- onPress,
- style,
-}: {
- label: string
- isSelected: boolean
- onPress: () => void
- style?: StyleProp
-}) {
- const t = useTheme()
- return (
-
- )
-}
diff --git a/src/components/icons/Eye.tsx b/src/components/icons/Eye.tsx
new file mode 100644
index 00000000..afa772e1
--- /dev/null
+++ b/src/components/icons/Eye.tsx
@@ -0,0 +1,5 @@
+import {createSinglePathSVG} from './TEMPLATE'
+
+export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({
+ path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z',
+})
diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx
index b8f02582..d95717cf 100644
--- a/src/components/moderation/ModerationDetailsDialog.tsx
+++ b/src/components/moderation/ModerationDetailsDialog.tsx
@@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection'
+import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link'
+import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control']
- modcause?: ModerationCause
+ modcause?: ModerationCause | AppModerationCause
}
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme()
const {_} = useLingui()
const desc = useModerationCauseDescription(modcause)
+ const {currentAccount} = useSession()
let name
let description
@@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`)
+ } else if (modcause.type === 'reply-hidden') {
+ const isYou = currentAccount?.did === modcause.source.did
+ name = isYou
+ ? _(msg`Reply Hidden by You`)
+ : _(msg`Reply Hidden by Thread Author`)
+ description = isYou
+ ? _(msg`You hid this reply.`)
+ : _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description
@@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
{name}
-
+
{description}
{modcause?.type === 'label' && (
- <>
+
{modcause.source.type === 'user' ? (
@@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
)}
- >
+
)}
{isNative && }
diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx
index efbf1821..6c4e5f8c 100644
--- a/src/components/moderation/PostAlerts.tsx
+++ b/src/components/moderation/PostAlerts.tsx
@@ -1,6 +1,6 @@
import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
-import {ModerationUI} from '@atproto/api'
+import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills'
@@ -9,13 +9,15 @@ export function PostAlerts({
modui,
size = 'sm',
style,
+ additionalCauses,
}: {
modui: ModerationUI
size?: Pills.CommonProps['size']
includeMute?: boolean
style?: StyleProp
+ additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) {
- if (!modui.alert && !modui.inform) {
+ if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null
}
@@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'}
/>
))}
+ {additionalCauses?.map(cause => (
+
+ ))}
)
}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 658ed78d..94c8869a 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -3,7 +3,7 @@ import {
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
- AppBskyFeedThreadgate,
+ AppBskyFeedPostgate,
BskyAgent,
ComAtprotoLabelDefs,
RichText,
@@ -11,7 +11,13 @@ import {
import {AtUri} from '@atproto/api'
import {logger} from '#/logger'
-import {ThreadgateSetting} from '#/state/queries/threadgate'
+import {writePostgateRecord} from '#/state/queries/postgate'
+import {
+ createThreadgateRecord,
+ ThreadgateAllowUISetting,
+ threadgateAllowUISettingToAllowRecordValue,
+ writeThreadgateRecord,
+} from '#/state/queries/threadgate'
import {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative} from 'platform/detection'
@@ -44,7 +50,8 @@ interface PostOpts {
extLink?: ExternalEmbedDraft
images?: ImageModel[]
labels?: string[]
- threadgate?: ThreadgateSetting[]
+ threadgate: ThreadgateAllowUISetting[]
+ postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void
langs?: string[]
}
@@ -232,7 +239,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
labels,
})
} catch (e: any) {
- console.error(`Failed to create post: ${e.toString()}`)
+ logger.error(`Failed to create post`, {
+ safeMessage: e.message,
+ })
if (isNetworkError(e)) {
throw new Error(
'Post failed to upload. Please check your Internet connection and try again.',
@@ -242,56 +251,52 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
}
}
- try {
- // TODO: this needs to be batch-created with the post!
- if (opts.threadgate?.length) {
- await createThreadgate(agent, res.uri, opts.threadgate)
+ if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
+ try {
+ // TODO: this needs to be batch-created with the post!
+ await writeThreadgateRecord({
+ agent,
+ postUri: res.uri,
+ threadgate: createThreadgateRecord({
+ post: res.uri,
+ allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
+ }),
+ })
+ } catch (e: any) {
+ logger.error(`Failed to create threadgate`, {
+ context: 'composer',
+ safeMessage: e.message,
+ })
+ throw new Error(
+ 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
+ )
+ }
+ }
+
+ if (
+ opts.postgate.embeddingRules?.length ||
+ opts.postgate.detachedEmbeddingUris?.length
+ ) {
+ try {
+ // TODO: this needs to be batch-created with the post!
+ await writePostgateRecord({
+ agent,
+ postUri: res.uri,
+ postgate: {
+ ...opts.postgate,
+ post: res.uri,
+ },
+ })
+ } catch (e: any) {
+ logger.error(`Failed to create postgate`, {
+ context: 'composer',
+ safeMessage: e.message,
+ })
+ throw new Error(
+ 'Failed to save post interaction settings. Your post was created but users may be able to interact with it.',
+ )
}
- } catch (e: any) {
- console.error(`Failed to create threadgate: ${e.toString()}`)
- throw new Error(
- 'Post reply-controls failed to be set. Your post was created but anyone can reply to it.',
- )
}
return res
}
-
-export async function createThreadgate(
- agent: BskyAgent,
- postUri: string,
- threadgate: ThreadgateSetting[],
-) {
- let allow: (
- | AppBskyFeedThreadgate.MentionRule
- | AppBskyFeedThreadgate.FollowingRule
- | AppBskyFeedThreadgate.ListRule
- )[] = []
- if (!threadgate.find(v => v.type === 'nobody')) {
- for (const rule of threadgate) {
- if (rule.type === 'mention') {
- allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
- } else if (rule.type === 'following') {
- allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
- } else if (rule.type === 'list') {
- allow.push({
- $type: 'app.bsky.feed.threadgate#listRule',
- list: rule.list,
- })
- }
- }
- }
-
- const postUrip = new AtUri(postUri)
- await agent.api.com.atproto.repo.putRecord({
- repo: agent.accountDid,
- collection: 'app.bsky.feed.threadgate',
- rkey: postUrip.rkey,
- record: {
- $type: 'app.bsky.feed.threadgate',
- post: postUri,
- allow,
- createdAt: new Date().toISOString(),
- },
- })
-}
diff --git a/src/lib/link-meta/bsky.ts b/src/lib/link-meta/bsky.ts
index e3b4ea0c..3d49f523 100644
--- a/src/lib/link-meta/bsky.ts
+++ b/src/lib/link-meta/bsky.ts
@@ -107,6 +107,11 @@ export async function extractBskyMeta(
return meta
}
+export class EmbeddingDisabledError extends Error {
+ constructor() {
+ super('Embedding is disabled for this record')
+ }
+}
export async function getPostAsQuote(
getPost: ReturnType,
url: string,
@@ -115,6 +120,9 @@ export async function getPostAsQuote(
const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri})
+ if (post.viewer?.embeddingDisabled) {
+ throw new EmbeddingDisabledError()
+ }
return {
uri: post.uri,
cid: post.cid,
diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts
index 4105c2c2..3c96deec 100644
--- a/src/lib/moderation.ts
+++ b/src/lib/moderation.ts
@@ -1,17 +1,20 @@
import {
- ModerationCause,
- ModerationUI,
- InterpretedLabelValueDefinition,
- LABELS,
AppBskyLabelerDefs,
BskyAgent,
+ InterpretedLabelValueDefinition,
+ LABELS,
+ ModerationCause,
ModerationOpts,
+ ModerationUI,
} from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
+import {AppModerationCause} from '#/components/Pills'
-export function getModerationCauseKey(cause: ModerationCause): string {
+export function getModerationCauseKey(
+ cause: ModerationCause | AppModerationCause,
+): string {
const source =
cause.source.type === 'labeler'
? cause.source.did
diff --git a/src/lib/moderation/useModerationCauseDescription.ts b/src/lib/moderation/useModerationCauseDescription.ts
index 01ffbe5c..9dce0b56 100644
--- a/src/lib/moderation/useModerationCauseDescription.ts
+++ b/src/lib/moderation/useModerationCauseDescription.ts
@@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useLabelDefinitions} from '#/state/preferences'
+import {useSession} from '#/state/session'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
+import {AppModerationCause} from '#/components/Pills'
import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo'
@@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
}
export function useModerationCauseDescription(
- cause: ModerationCause | undefined,
+ cause: ModerationCause | AppModerationCause | undefined,
): ModerationCauseDescription {
+ const {currentAccount} = useSession()
const {_, i18n} = useLingui()
const {labelDefs, labelers} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings()
@@ -111,6 +114,18 @@ export function useModerationCauseDescription(
description: _(msg`You have hidden this post`),
}
}
+ if (cause.type === 'reply-hidden') {
+ const isMe = currentAccount?.did === cause.source.did
+ return {
+ icon: EyeSlash,
+ name: isMe
+ ? _(msg`Reply Hidden by You`)
+ : _(msg`Reply Hidden by Thread Author`),
+ description: isMe
+ ? _(msg`You hid this reply.`)
+ : _(msg`The author of this thread has hidden this reply.`),
+ }
+ }
if (cause.type === 'label') {
const def = cause.labelDef || getDefinition(labelDefs, cause.label)
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
@@ -150,5 +165,13 @@ export function useModerationCauseDescription(
name: '',
description: ``,
}
- }, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale])
+ }, [
+ labelDefs,
+ labelers,
+ globalLabelStrings,
+ cause,
+ _,
+ i18n.locale,
+ currentAccount?.did,
+ ])
}
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index b37e9bd4..65300a8e 100644
--- a/src/state/cache/post-shadow.ts
+++ b/src/state/cache/post-shadow.ts
@@ -1,5 +1,9 @@
import {useEffect, useMemo, useState} from 'react'
-import {AppBskyFeedDefs} from '@atproto/api'
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+} from '@atproto/api'
import {QueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3'
@@ -16,6 +20,7 @@ export interface PostShadow {
likeUri: string | undefined
repostUri: string | undefined
isDeleted: boolean
+ embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
}
export const POST_TOMBSTONE = Symbol('PostTombstone')
@@ -87,8 +92,21 @@ function mergeShadow(
repostCount = Math.max(0, repostCount)
}
+ let embed: typeof post.embed
+ if ('embed' in shadow) {
+ if (
+ (AppBskyEmbedRecord.isView(post.embed) &&
+ AppBskyEmbedRecord.isView(shadow.embed)) ||
+ (AppBskyEmbedRecordWithMedia.isView(post.embed) &&
+ AppBskyEmbedRecordWithMedia.isView(shadow.embed))
+ ) {
+ embed = shadow.embed
+ }
+ }
+
return castAsShadow({
...post,
+ embed: embed || post.embed,
likeCount: likeCount,
repostCount: repostCount,
viewer: {
diff --git a/src/state/queries/notifications/feed.ts b/src/state/queries/notifications/feed.ts
index 997076e8..55e04830 100644
--- a/src/state/queries/notifications/feed.ts
+++ b/src/state/queries/notifications/feed.ts
@@ -16,7 +16,7 @@
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead.
*/
-import {useEffect, useRef} from 'react'
+import {useCallback, useEffect, useMemo, useRef} from 'react'
import {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import {
InfiniteData,
@@ -27,6 +27,7 @@ import {
} from '@tanstack/react-query'
import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..'
import {
@@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false
+ const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
// false: force showing all notifications
// undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined
+ const selectArgs = useMemo(() => {
+ return {
+ hiddenReplyUris,
+ }
+ }, [hiddenReplyUris])
+
const query = useInfiniteQuery<
FeedPage,
Error,
@@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: {
initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor,
enabled,
- select(data: InfiniteData) {
- // override 'isRead' using the first page's returned seenAt
- // we do this because the `markAllRead()` call above will
- // mark subsequent pages as read prematurely
- const seenAt = data.pages[0]?.seenAt || new Date()
- for (const page of data.pages) {
- for (const item of page.items) {
- item.notification.isRead =
- seenAt > new Date(item.notification.indexedAt)
- }
- }
+ select: useCallback(
+ (data: InfiniteData) => {
+ const {hiddenReplyUris} = selectArgs
- return data
- },
+ // override 'isRead' using the first page's returned seenAt
+ // we do this because the `markAllRead()` call above will
+ // mark subsequent pages as read prematurely
+ const seenAt = data.pages[0]?.seenAt || new Date()
+ for (const page of data.pages) {
+ for (const item of page.items) {
+ item.notification.isRead =
+ seenAt > new Date(item.notification.indexedAt)
+ }
+ }
+
+ data = {
+ ...data,
+ pages: data.pages.map(page => {
+ return {
+ ...page,
+ items: page.items.filter(item => {
+ const isHiddenReply =
+ item.type === 'reply' &&
+ item.subjectUri &&
+ hiddenReplyUris.has(item.subjectUri)
+ return !isHiddenReply
+ }),
+ }
+ }),
+ }
+
+ return data
+ },
+ [selectArgs],
+ ),
})
// The server may end up returning an empty page, a page with too few items,
diff --git a/src/state/queries/post-thread.ts b/src/state/queries/post-thread.ts
index fd419d1c..3370c361 100644
--- a/src/state/queries/post-thread.ts
+++ b/src/state/queries/post-thread.ts
@@ -138,6 +138,7 @@ export function sortThread(
modCache: ThreadModerationCache,
currentDid: string | undefined,
justPostedUris: Set,
+ threadgateRecordHiddenReplies: Set,
): ThreadNode {
if (node.type !== 'post') {
return node
@@ -185,6 +186,14 @@ export function sortThread(
return 1 // current account's reply
}
+ const aHidden = threadgateRecordHiddenReplies.has(a.uri)
+ const bHidden = threadgateRecordHiddenReplies.has(b.uri)
+ if (aHidden && !aIsBySelf && !bHidden) {
+ return 1
+ } else if (bHidden && !bIsBySelf && !aHidden) {
+ return -1
+ }
+
const aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
if (aBlur !== bBlur) {
@@ -222,7 +231,14 @@ export function sortThread(
return b.post.indexedAt.localeCompare(a.post.indexedAt)
})
node.replies.forEach(reply =>
- sortThread(reply, opts, modCache, currentDid, justPostedUris),
+ sortThread(
+ reply,
+ opts,
+ modCache,
+ currentDid,
+ justPostedUris,
+ threadgateRecordHiddenReplies,
+ ),
)
}
return node
diff --git a/src/state/queries/post.ts b/src/state/queries/post.ts
index 071a2e91..197903be 100644
--- a/src/state/queries/post.ts
+++ b/src/state/queries/post.ts
@@ -73,6 +73,30 @@ export function useGetPost() {
)
}
+export function useGetPosts() {
+ const queryClient = useQueryClient()
+ const agent = useAgent()
+ return useCallback(
+ async ({uris}: {uris: string[]}) => {
+ return queryClient.fetchQuery({
+ queryKey: RQKEY(uris.join(',') || ''),
+ async queryFn() {
+ const res = await agent.getPosts({
+ uris,
+ })
+
+ if (res.success) {
+ return res.data.posts
+ } else {
+ throw new Error('useGetPosts failed')
+ }
+ },
+ })
+ },
+ [queryClient, agent],
+ )
+}
+
export function usePostLikeMutationQueue(
post: Shadow,
logContext: LogEvents['post:like']['logContext'] &
diff --git a/src/state/queries/postgate/index.ts b/src/state/queries/postgate/index.ts
new file mode 100644
index 00000000..149b9cbe
--- /dev/null
+++ b/src/state/queries/postgate/index.ts
@@ -0,0 +1,295 @@
+import React from 'react'
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+ AppBskyFeedPostgate,
+ AtUri,
+ BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {logger} from '#/logger'
+import {updatePostShadow} from '#/state/cache/post-shadow'
+import {STALE} from '#/state/queries'
+import {useGetPosts} from '#/state/queries/post'
+import {
+ createMaybeDetachedQuoteEmbed,
+ createPostgateRecord,
+ mergePostgateRecords,
+ POSTGATE_COLLECTION,
+} from '#/state/queries/postgate/util'
+import {useAgent} from '#/state/session'
+
+export async function getPostgateRecord({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}): Promise {
+ const urip = new AtUri(postUri)
+
+ if (!urip.host.startsWith('did:')) {
+ const res = await agent.resolveHandle({
+ handle: urip.host,
+ })
+ urip.host = res.data.did
+ }
+
+ try {
+ const {data} = await retry(
+ 2,
+ e => {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return false
+ }
+ return true
+ },
+ () =>
+ agent.api.com.atproto.repo.getRecord({
+ repo: urip.host,
+ collection: POSTGATE_COLLECTION,
+ rkey: urip.rkey,
+ }),
+ )
+
+ if (data.value && AppBskyFeedPostgate.isRecord(data.value)) {
+ return data.value
+ } else {
+ return undefined
+ }
+ } catch (e: any) {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return undefined
+ } else {
+ throw e
+ }
+ }
+}
+
+export async function writePostgateRecord({
+ agent,
+ postUri,
+ postgate,
+}: {
+ agent: BskyAgent
+ postUri: string
+ postgate: AppBskyFeedPostgate.Record
+}) {
+ const postUrip = new AtUri(postUri)
+
+ await networkRetry(2, () =>
+ agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: POSTGATE_COLLECTION,
+ rkey: postUrip.rkey,
+ record: postgate,
+ }),
+ )
+}
+
+export async function upsertPostgate(
+ {
+ agent,
+ postUri,
+ }: {
+ agent: BskyAgent
+ postUri: string
+ },
+ callback: (
+ postgate: AppBskyFeedPostgate.Record | undefined,
+ ) => Promise,
+) {
+ const prev = await getPostgateRecord({
+ agent,
+ postUri,
+ })
+ const next = await callback(prev)
+ if (!next) return
+ await writePostgateRecord({
+ agent,
+ postUri,
+ postgate: next,
+ })
+}
+
+export const createPostgateQueryKey = (postUri: string) => [
+ 'postgate-record',
+ postUri,
+]
+export function usePostgateQuery({postUri}: {postUri: string}) {
+ const agent = useAgent()
+ return useQuery({
+ staleTime: STALE.SECONDS.THIRTY,
+ queryKey: createPostgateQueryKey(postUri),
+ async queryFn() {
+ return (await getPostgateRecord({agent, postUri})) ?? null
+ },
+ })
+}
+
+export function useWritePostgateMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ postgate,
+ }: {
+ postUri: string
+ postgate: AppBskyFeedPostgate.Record
+ }) => {
+ return writePostgateRecord({
+ agent,
+ postUri,
+ postgate,
+ })
+ },
+ onSuccess(_, {postUri}) {
+ queryClient.invalidateQueries({
+ queryKey: createPostgateQueryKey(postUri),
+ })
+ },
+ })
+}
+
+export function useToggleQuoteDetachmentMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const getPosts = useGetPosts()
+ const prevEmbed = React.useRef()
+
+ return useMutation({
+ mutationFn: async ({
+ post,
+ quoteUri,
+ action,
+ }: {
+ post: AppBskyFeedDefs.PostView
+ quoteUri: string
+ action: 'detach' | 'reattach'
+ }) => {
+ // cache here since post shadow mutates original object
+ prevEmbed.current = post.embed
+
+ if (action === 'detach') {
+ updatePostShadow(queryClient, post.uri, {
+ embed: createMaybeDetachedQuoteEmbed({
+ post,
+ quote: undefined,
+ quoteUri,
+ detached: true,
+ }),
+ })
+ }
+
+ await upsertPostgate({agent, postUri: quoteUri}, async prev => {
+ if (prev) {
+ if (action === 'detach') {
+ return mergePostgateRecords(prev, {
+ detachedEmbeddingUris: [post.uri],
+ })
+ } else if (action === 'reattach') {
+ return {
+ ...prev,
+ detachedEmbeddingUris:
+ prev.detachedEmbeddingUris?.filter(uri => uri !== post.uri) ||
+ [],
+ }
+ }
+ } else {
+ if (action === 'detach') {
+ return createPostgateRecord({
+ post: quoteUri,
+ detachedEmbeddingUris: [post.uri],
+ })
+ }
+ }
+ })
+ },
+ async onSuccess(_data, {post, quoteUri, action}) {
+ if (action === 'reattach') {
+ try {
+ const [quote] = await getPosts({uris: [quoteUri]})
+ updatePostShadow(queryClient, post.uri, {
+ embed: createMaybeDetachedQuoteEmbed({
+ post,
+ quote,
+ quoteUri: undefined,
+ detached: false,
+ }),
+ })
+ } catch (e: any) {
+ // ok if this fails, it's just optimistic UI
+ logger.error(`Postgate: failed to get quote post for re-attachment`, {
+ safeMessage: e.message,
+ })
+ }
+ }
+ },
+ onError(_, {post, action}) {
+ if (action === 'detach' && prevEmbed.current) {
+ // detach failed, add the embed back
+ if (
+ AppBskyEmbedRecord.isView(prevEmbed.current) ||
+ AppBskyEmbedRecordWithMedia.isView(prevEmbed.current)
+ ) {
+ updatePostShadow(queryClient, post.uri, {
+ embed: prevEmbed.current,
+ })
+ }
+ }
+ },
+ onSettled() {
+ prevEmbed.current = undefined
+ },
+ })
+}
+
+export function useToggleQuotepostEnabledMutation() {
+ const agent = useAgent()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ action,
+ }: {
+ postUri: string
+ action: 'enable' | 'disable'
+ }) => {
+ await upsertPostgate({agent, postUri: postUri}, async prev => {
+ if (prev) {
+ if (action === 'disable') {
+ return mergePostgateRecords(prev, {
+ embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+ })
+ } else if (action === 'enable') {
+ return {
+ ...prev,
+ embeddingRules: [],
+ }
+ }
+ } else {
+ if (action === 'disable') {
+ return createPostgateRecord({
+ post: postUri,
+ embeddingRules: [{$type: 'app.bsky.feed.postgate#disableRule'}],
+ })
+ }
+ }
+ })
+ },
+ })
+}
diff --git a/src/state/queries/postgate/util.ts b/src/state/queries/postgate/util.ts
new file mode 100644
index 00000000..21509c3a
--- /dev/null
+++ b/src/state/queries/postgate/util.ts
@@ -0,0 +1,196 @@
+import {
+ AppBskyEmbedRecord,
+ AppBskyEmbedRecordWithMedia,
+ AppBskyFeedDefs,
+ AppBskyFeedPostgate,
+ AtUri,
+} from '@atproto/api'
+
+export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
+
+export function createPostgateRecord(
+ postgate: Partial & {
+ post: AppBskyFeedPostgate.Record['post']
+ },
+): AppBskyFeedPostgate.Record {
+ return {
+ $type: POSTGATE_COLLECTION,
+ createdAt: new Date().toISOString(),
+ post: postgate.post,
+ detachedEmbeddingUris: postgate.detachedEmbeddingUris || [],
+ embeddingRules: postgate.embeddingRules || [],
+ }
+}
+
+export function mergePostgateRecords(
+ prev: AppBskyFeedPostgate.Record,
+ next: Partial,
+) {
+ const detachedEmbeddingUris = Array.from(
+ new Set([
+ ...(prev.detachedEmbeddingUris || []),
+ ...(next.detachedEmbeddingUris || []),
+ ]),
+ )
+ const embeddingRules = [
+ ...(prev.embeddingRules || []),
+ ...(next.embeddingRules || []),
+ ].filter(
+ (rule, i, all) => all.findIndex(_rule => _rule.$type === rule.$type) === i,
+ )
+ return createPostgateRecord({
+ post: prev.post,
+ detachedEmbeddingUris,
+ embeddingRules,
+ })
+}
+
+export function createEmbedViewDetachedRecord({uri}: {uri: string}) {
+ const record: AppBskyEmbedRecord.ViewDetached = {
+ $type: 'app.bsky.embed.record#viewDetached',
+ uri,
+ detached: true,
+ }
+ return {
+ $type: 'app.bsky.embed.record#view',
+ record,
+ }
+}
+
+export function createMaybeDetachedQuoteEmbed({
+ post,
+ quote,
+ quoteUri,
+ detached,
+}:
+ | {
+ post: AppBskyFeedDefs.PostView
+ quote: AppBskyFeedDefs.PostView
+ quoteUri: undefined
+ detached: false
+ }
+ | {
+ post: AppBskyFeedDefs.PostView
+ quote: undefined
+ quoteUri: string
+ detached: true
+ }): AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined {
+ if (AppBskyEmbedRecord.isView(post.embed)) {
+ if (detached) {
+ return createEmbedViewDetachedRecord({uri: quoteUri})
+ } else {
+ return createEmbedRecordView({post: quote})
+ }
+ } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+ if (detached) {
+ return {
+ ...post.embed,
+ record: createEmbedViewDetachedRecord({uri: quoteUri}),
+ }
+ } else {
+ return createEmbedRecordWithMediaView({post, quote})
+ }
+ }
+}
+
+export function createEmbedViewRecordFromPost(
+ post: AppBskyFeedDefs.PostView,
+): AppBskyEmbedRecord.ViewRecord {
+ return {
+ $type: 'app.bsky.embed.record#viewRecord',
+ uri: post.uri,
+ cid: post.cid,
+ author: post.author,
+ value: post.record,
+ labels: post.labels,
+ replyCount: post.replyCount,
+ repostCount: post.repostCount,
+ likeCount: post.likeCount,
+ indexedAt: post.indexedAt,
+ }
+}
+
+export function createEmbedRecordView({
+ post,
+}: {
+ post: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecord.View {
+ return {
+ $type: 'app.bsky.embed.record#view',
+ record: createEmbedViewRecordFromPost(post),
+ }
+}
+
+export function createEmbedRecordWithMediaView({
+ post,
+ quote,
+}: {
+ post: AppBskyFeedDefs.PostView
+ quote: AppBskyFeedDefs.PostView
+}): AppBskyEmbedRecordWithMedia.View | undefined {
+ if (!AppBskyEmbedRecordWithMedia.isView(post.embed)) return
+ return {
+ ...(post.embed || {}),
+ record: {
+ record: createEmbedViewRecordFromPost(quote),
+ },
+ }
+}
+
+export function getMaybeDetachedQuoteEmbed({
+ viewerDid,
+ post,
+}: {
+ viewerDid: string
+ post: AppBskyFeedDefs.PostView
+}) {
+ if (AppBskyEmbedRecord.isView(post.embed)) {
+ // detached
+ if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) {
+ const urip = new AtUri(post.embed.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: true,
+ }
+ }
+
+ // post
+ if (AppBskyEmbedRecord.isViewRecord(post.embed.record)) {
+ const urip = new AtUri(post.embed.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: false,
+ }
+ }
+ } else if (AppBskyEmbedRecordWithMedia.isView(post.embed)) {
+ // detached
+ if (AppBskyEmbedRecord.isViewDetached(post.embed.record.record)) {
+ const urip = new AtUri(post.embed.record.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: true,
+ }
+ }
+
+ // post
+ if (AppBskyEmbedRecord.isViewRecord(post.embed.record.record)) {
+ const urip = new AtUri(post.embed.record.record.uri)
+ return {
+ embed: post.embed,
+ uri: urip.toString(),
+ isOwnedByViewer: urip.host === viewerDid,
+ isDetached: false,
+ }
+ }
+ }
+}
+
+export const embeddingRules = {
+ disableRule: {$type: 'app.bsky.feed.postgate#disableRule'},
+}
diff --git a/src/state/queries/threadgate.ts b/src/state/queries/threadgate.ts
deleted file mode 100644
index 8b6aeba6..00000000
--- a/src/state/queries/threadgate.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
-
-export type ThreadgateSetting =
- | {type: 'nobody'}
- | {type: 'mention'}
- | {type: 'following'}
- | {type: 'list'; list: unknown}
-
-export function threadgateViewToSettings(
- threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
-): ThreadgateSetting[] {
- const record =
- threadgate &&
- AppBskyFeedThreadgate.isRecord(threadgate.record) &&
- AppBskyFeedThreadgate.validateRecord(threadgate.record).success
- ? threadgate.record
- : null
- if (!record) {
- return []
- }
- if (!record.allow?.length) {
- return [{type: 'nobody'}]
- }
- const settings: ThreadgateSetting[] = record.allow
- .map(allow => {
- let setting: ThreadgateSetting | undefined
- if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
- setting = {type: 'mention'}
- } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
- setting = {type: 'following'}
- } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
- setting = {type: 'list', list: allow.list}
- }
- return setting
- })
- .filter(n => !!n)
- return settings
-}
diff --git a/src/state/queries/threadgate/index.ts b/src/state/queries/threadgate/index.ts
new file mode 100644
index 00000000..a88197cd
--- /dev/null
+++ b/src/state/queries/threadgate/index.ts
@@ -0,0 +1,358 @@
+import {
+ AppBskyFeedDefs,
+ AppBskyFeedGetPostThread,
+ AppBskyFeedThreadgate,
+ AtUri,
+ BskyAgent,
+} from '@atproto/api'
+import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
+
+import {networkRetry, retry} from '#/lib/async/retry'
+import {until} from '#/lib/async/until'
+import {STALE} from '#/state/queries'
+import {RQKEY_ROOT as postThreadQueryKeyRoot} from '#/state/queries/post-thread'
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+import {
+ createThreadgateRecord,
+ mergeThreadgateRecords,
+ threadgateAllowUISettingToAllowRecordValue,
+ threadgateViewToAllowUISetting,
+} from '#/state/queries/threadgate/util'
+import {useAgent} from '#/state/session'
+import {useThreadgateHiddenReplyUrisAPI} from '#/state/threadgate-hidden-replies'
+
+export * from '#/state/queries/threadgate/types'
+export * from '#/state/queries/threadgate/util'
+
+export const threadgateRecordQueryKeyRoot = 'threadgate-record'
+export const createThreadgateRecordQueryKey = (uri: string) => [
+ threadgateRecordQueryKeyRoot,
+ uri,
+]
+
+export function useThreadgateRecordQuery({
+ enabled,
+ postUri,
+ initialData,
+}: {
+ enabled?: boolean
+ postUri?: string
+ initialData?: AppBskyFeedThreadgate.Record
+} = {}) {
+ const agent = useAgent()
+
+ return useQuery({
+ enabled: enabled ?? !!postUri,
+ queryKey: createThreadgateRecordQueryKey(postUri || ''),
+ placeholderData: initialData,
+ staleTime: STALE.MINUTES.ONE,
+ async queryFn() {
+ return getThreadgateRecord({
+ agent,
+ postUri: postUri!,
+ })
+ },
+ })
+}
+
+export const threadgateViewQueryKeyRoot = 'threadgate-view'
+export const createThreadgateViewQueryKey = (uri: string) => [
+ threadgateViewQueryKeyRoot,
+ uri,
+]
+export function useThreadgateViewQuery({
+ postUri,
+ initialData,
+}: {
+ postUri?: string
+ initialData?: AppBskyFeedDefs.ThreadgateView
+} = {}) {
+ const agent = useAgent()
+
+ return useQuery({
+ enabled: !!postUri,
+ queryKey: createThreadgateViewQueryKey(postUri || ''),
+ placeholderData: initialData,
+ staleTime: STALE.MINUTES.ONE,
+ async queryFn() {
+ return getThreadgateView({
+ agent,
+ postUri: postUri!,
+ })
+ },
+ })
+}
+
+export async function getThreadgateView({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}) {
+ const {data} = await agent.app.bsky.feed.getPostThread({
+ uri: postUri!,
+ depth: 0,
+ })
+
+ if (AppBskyFeedDefs.isThreadViewPost(data.thread)) {
+ return data.thread.post.threadgate ?? null
+ }
+
+ return null
+}
+
+export async function getThreadgateRecord({
+ agent,
+ postUri,
+}: {
+ agent: BskyAgent
+ postUri: string
+}): Promise {
+ const urip = new AtUri(postUri)
+
+ if (!urip.host.startsWith('did:')) {
+ const res = await agent.resolveHandle({
+ handle: urip.host,
+ })
+ urip.host = res.data.did
+ }
+
+ try {
+ const {data} = await retry(
+ 2,
+ e => {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return false
+ }
+ return true
+ },
+ () =>
+ agent.api.com.atproto.repo.getRecord({
+ repo: urip.host,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: urip.rkey,
+ }),
+ )
+
+ if (data.value && AppBskyFeedThreadgate.isRecord(data.value)) {
+ return data.value
+ } else {
+ return null
+ }
+ } catch (e: any) {
+ /*
+ * If the record doesn't exist, we want to return null instead of
+ * throwing an error. NB: This will also catch reference errors, such as
+ * a typo in the URI.
+ */
+ if (e.message.includes(`Could not locate record:`)) {
+ return null
+ } else {
+ throw e
+ }
+ }
+}
+
+export async function writeThreadgateRecord({
+ agent,
+ postUri,
+ threadgate,
+}: {
+ agent: BskyAgent
+ postUri: string
+ threadgate: AppBskyFeedThreadgate.Record
+}) {
+ const postUrip = new AtUri(postUri)
+ const record = createThreadgateRecord({
+ post: postUri,
+ allow: threadgate.allow, // can/should be undefined!
+ hiddenReplies: threadgate.hiddenReplies || [],
+ })
+
+ await networkRetry(2, () =>
+ agent.api.com.atproto.repo.putRecord({
+ repo: agent.session!.did,
+ collection: 'app.bsky.feed.threadgate',
+ rkey: postUrip.rkey,
+ record,
+ }),
+ )
+}
+
+export async function upsertThreadgate(
+ {
+ agent,
+ postUri,
+ }: {
+ agent: BskyAgent
+ postUri: string
+ },
+ callback: (
+ threadgate: AppBskyFeedThreadgate.Record | null,
+ ) => Promise,
+) {
+ const prev = await getThreadgateRecord({
+ agent,
+ postUri,
+ })
+ const next = await callback(prev)
+ if (!next) return
+ await writeThreadgateRecord({
+ agent,
+ postUri,
+ threadgate: next,
+ })
+}
+
+/**
+ * Update the allow list for a threadgate record.
+ */
+export async function updateThreadgateAllow({
+ agent,
+ postUri,
+ allow,
+}: {
+ agent: BskyAgent
+ postUri: string
+ allow: ThreadgateAllowUISetting[]
+}) {
+ return upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ return {
+ ...prev,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ }
+ } else {
+ return createThreadgateRecord({
+ post: postUri,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ })
+ }
+ })
+}
+
+export function useSetThreadgateAllowMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ allow,
+ }: {
+ postUri: string
+ allow: ThreadgateAllowUISetting[]
+ }) => {
+ return upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ return {
+ ...prev,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ }
+ } else {
+ return createThreadgateRecord({
+ post: postUri,
+ allow: threadgateAllowUISettingToAllowRecordValue(allow),
+ })
+ }
+ })
+ },
+ async onSuccess(_, {postUri, allow}) {
+ await until(
+ 5, // 5 tries
+ 1e3, // 1s delay between tries
+ (res: AppBskyFeedGetPostThread.Response) => {
+ const thread = res.data.thread
+ if (AppBskyFeedDefs.isThreadViewPost(thread)) {
+ const fetchedSettings = threadgateViewToAllowUISetting(
+ thread.post.threadgate,
+ )
+ return JSON.stringify(fetchedSettings) === JSON.stringify(allow)
+ }
+ return false
+ },
+ () => {
+ return agent.app.bsky.feed.getPostThread({
+ uri: postUri,
+ depth: 0,
+ })
+ },
+ )
+
+ queryClient.invalidateQueries({
+ queryKey: [postThreadQueryKeyRoot],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [threadgateRecordQueryKeyRoot],
+ })
+ queryClient.invalidateQueries({
+ queryKey: [threadgateViewQueryKeyRoot],
+ })
+ },
+ })
+}
+
+export function useToggleReplyVisibilityMutation() {
+ const agent = useAgent()
+ const queryClient = useQueryClient()
+ const hiddenReplies = useThreadgateHiddenReplyUrisAPI()
+
+ return useMutation({
+ mutationFn: async ({
+ postUri,
+ replyUri,
+ action,
+ }: {
+ postUri: string
+ replyUri: string
+ action: 'hide' | 'show'
+ }) => {
+ if (action === 'hide') {
+ hiddenReplies.addHiddenReplyUri(replyUri)
+ } else if (action === 'show') {
+ hiddenReplies.removeHiddenReplyUri(replyUri)
+ }
+
+ await upsertThreadgate({agent, postUri}, async prev => {
+ if (prev) {
+ if (action === 'hide') {
+ return mergeThreadgateRecords(prev, {
+ hiddenReplies: [replyUri],
+ })
+ } else if (action === 'show') {
+ return {
+ ...prev,
+ hiddenReplies:
+ prev.hiddenReplies?.filter(uri => uri !== replyUri) || [],
+ }
+ }
+ } else {
+ if (action === 'hide') {
+ return createThreadgateRecord({
+ post: postUri,
+ hiddenReplies: [replyUri],
+ })
+ }
+ }
+ })
+ },
+ onSuccess() {
+ queryClient.invalidateQueries({
+ queryKey: [threadgateRecordQueryKeyRoot],
+ })
+ },
+ onError(_, {replyUri, action}) {
+ if (action === 'hide') {
+ hiddenReplies.removeHiddenReplyUri(replyUri)
+ } else if (action === 'show') {
+ hiddenReplies.addHiddenReplyUri(replyUri)
+ }
+ },
+ })
+}
diff --git a/src/state/queries/threadgate/types.ts b/src/state/queries/threadgate/types.ts
new file mode 100644
index 00000000..0cbea311
--- /dev/null
+++ b/src/state/queries/threadgate/types.ts
@@ -0,0 +1,6 @@
+export type ThreadgateAllowUISetting =
+ | {type: 'everybody'}
+ | {type: 'nobody'}
+ | {type: 'mention'}
+ | {type: 'following'}
+ | {type: 'list'; list: unknown}
diff --git a/src/state/queries/threadgate/util.ts b/src/state/queries/threadgate/util.ts
new file mode 100644
index 00000000..09ae0a0c
--- /dev/null
+++ b/src/state/queries/threadgate/util.ts
@@ -0,0 +1,141 @@
+import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
+
+import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types'
+
+export function threadgateViewToAllowUISetting(
+ threadgateView: AppBskyFeedDefs.ThreadgateView | undefined,
+): ThreadgateAllowUISetting[] {
+ const threadgate =
+ threadgateView &&
+ AppBskyFeedThreadgate.isRecord(threadgateView.record) &&
+ AppBskyFeedThreadgate.validateRecord(threadgateView.record).success
+ ? threadgateView.record
+ : undefined
+ return threadgateRecordToAllowUISetting(threadgate)
+}
+
+/**
+ * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of
+ * {@link ThreadgateAllowUISetting}, for use by app UI.
+ */
+export function threadgateRecordToAllowUISetting(
+ threadgate: AppBskyFeedThreadgate.Record | undefined,
+): ThreadgateAllowUISetting[] {
+ /*
+ * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means
+ * anyone can reply.
+ *
+ * If `threadgate.allow === []` it means no one can reply, and we translate to UI code
+ * here. This was a historical choice, and we have no lexicon representation
+ * for 'replies disabled' other than an empty array.
+ */
+ if (!threadgate || threadgate.allow === undefined) {
+ return [{type: 'everybody'}]
+ }
+ if (threadgate.allow.length === 0) {
+ return [{type: 'nobody'}]
+ }
+
+ const settings: ThreadgateAllowUISetting[] = threadgate.allow
+ .map(allow => {
+ let setting: ThreadgateAllowUISetting | undefined
+ if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
+ setting = {type: 'mention'}
+ } else if (allow.$type === 'app.bsky.feed.threadgate#followingRule') {
+ setting = {type: 'following'}
+ } else if (allow.$type === 'app.bsky.feed.threadgate#listRule') {
+ setting = {type: 'list', list: allow.list}
+ }
+ return setting
+ })
+ .filter(n => !!n)
+ return settings
+}
+
+/**
+ * Converts an array of {@link ThreadgateAllowUISetting} to the `allow` prop on
+ * {@link AppBskyFeedThreadgate.Record}.
+ *
+ * If the `allow` property on the record is undefined, we infer that to mean
+ * that everyone can reply. If it's an empty array, we infer that to mean that
+ * no one can reply.
+ */
+export function threadgateAllowUISettingToAllowRecordValue(
+ threadgate: ThreadgateAllowUISetting[],
+): AppBskyFeedThreadgate.Record['allow'] {
+ if (threadgate.find(v => v.type === 'everybody')) {
+ return undefined
+ }
+
+ let allow: (
+ | AppBskyFeedThreadgate.MentionRule
+ | AppBskyFeedThreadgate.FollowingRule
+ | AppBskyFeedThreadgate.ListRule
+ )[] = []
+
+ if (!threadgate.find(v => v.type === 'nobody')) {
+ for (const rule of threadgate) {
+ if (rule.type === 'mention') {
+ allow.push({$type: 'app.bsky.feed.threadgate#mentionRule'})
+ } else if (rule.type === 'following') {
+ allow.push({$type: 'app.bsky.feed.threadgate#followingRule'})
+ } else if (rule.type === 'list') {
+ allow.push({
+ $type: 'app.bsky.feed.threadgate#listRule',
+ list: rule.list,
+ })
+ }
+ }
+ }
+
+ return allow
+}
+
+/**
+ * Merges two {@link AppBskyFeedThreadgate.Record} objects, combining their
+ * `allow` and `hiddenReplies` arrays and de-deduplicating them.
+ *
+ * Note: `allow` can be undefined here, be sure you don't accidentally set it
+ * to an empty array. See other comments in this file.
+ */
+export function mergeThreadgateRecords(
+ prev: AppBskyFeedThreadgate.Record,
+ next: Partial,
+): AppBskyFeedThreadgate.Record {
+ // can be undefined if everyone can reply!
+ const allow: AppBskyFeedThreadgate.Record['allow'] | undefined =
+ prev.allow || next.allow
+ ? [...(prev.allow || []), ...(next.allow || [])].filter(
+ (v, i, a) => a.findIndex(t => t.$type === v.$type) === i,
+ )
+ : undefined
+ const hiddenReplies = Array.from(
+ new Set([...(prev.hiddenReplies || []), ...(next.hiddenReplies || [])]),
+ )
+
+ return createThreadgateRecord({
+ post: prev.post,
+ allow, // can be undefined!
+ hiddenReplies,
+ })
+}
+
+/**
+ * Create a new {@link AppBskyFeedThreadgate.Record} object with the given
+ * properties.
+ */
+export function createThreadgateRecord(
+ threadgate: Partial,
+): AppBskyFeedThreadgate.Record {
+ if (!threadgate.post) {
+ throw new Error('Cannot create a threadgate record without a post URI')
+ }
+
+ return {
+ $type: 'app.bsky.feed.threadgate',
+ post: threadgate.post,
+ createdAt: new Date().toISOString(),
+ allow: threadgate.allow, // can be undefined!
+ hiddenReplies: threadgate.hiddenReplies || [],
+ }
+}
diff --git a/src/state/threadgate-hidden-replies.tsx b/src/state/threadgate-hidden-replies.tsx
new file mode 100644
index 00000000..06fc2236
--- /dev/null
+++ b/src/state/threadgate-hidden-replies.tsx
@@ -0,0 +1,69 @@
+import React from 'react'
+
+type StateContext = {
+ uris: Set
+ recentlyUnhiddenUris: Set
+}
+type ApiContext = {
+ addHiddenReplyUri: (uri: string) => void
+ removeHiddenReplyUri: (uri: string) => void
+}
+
+const StateContext = React.createContext({
+ uris: new Set(),
+ recentlyUnhiddenUris: new Set(),
+})
+
+const ApiContext = React.createContext({
+ addHiddenReplyUri: () => {},
+ removeHiddenReplyUri: () => {},
+})
+
+export function Provider({children}: {children: React.ReactNode}) {
+ const [uris, setHiddenReplyUris] = React.useState>(new Set())
+ const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<
+ Set
+ >(new Set())
+
+ const stateCtx = React.useMemo(
+ () => ({
+ uris,
+ recentlyUnhiddenUris,
+ }),
+ [uris, recentlyUnhiddenUris],
+ )
+
+ const apiCtx = React.useMemo(
+ () => ({
+ addHiddenReplyUri(uri: string) {
+ setHiddenReplyUris(prev => new Set(prev.add(uri)))
+ setRecentlyUnhiddenUris(prev => {
+ prev.delete(uri)
+ return new Set(prev)
+ })
+ },
+ removeHiddenReplyUri(uri: string) {
+ setHiddenReplyUris(prev => {
+ prev.delete(uri)
+ return new Set(prev)
+ })
+ setRecentlyUnhiddenUris(prev => new Set(prev.add(uri)))
+ },
+ }),
+ [setHiddenReplyUris],
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useThreadgateHiddenReplyUris() {
+ return React.useContext(StateContext)
+}
+
+export function useThreadgateHiddenReplyUrisAPI() {
+ return React.useContext(ApiContext)
+}
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 0efbe70e..eefd0aff 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()
const [labels, setLabels] = useState([])
- const [threadgate, setThreadgate] = useState([])
+ const [threadgateAllowUISettings, onChangeThreadgateAllowUISettings] =
+ useState(
+ 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({
)}
{error !== '' && (
-
-
-
+
+
+
+
+ {error}
+
+
- {error}
)}
@@ -680,8 +715,12 @@ export const ComposePost = observer(function ComposePost({
{replyTo ? null : (
)}
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 6cf2eea2..666473af 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>
}) {
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`,
)}>
-
+
{label}
- {
+ 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 2938ea25..31751443 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(
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 d573d27a..f91a041d 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
+ return
}
function keyExtractor(item: {
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index c64be8d6..bd778fd9 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(
+ 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}) {
,
): 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,
): Generator {
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 26a5f2f0..da187f5d 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
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 ? (
@@ -540,6 +567,7 @@ let PostThreadItemLoaded = ({
{richText?.text ? (
@@ -571,6 +599,7 @@ let PostThreadItemLoaded = ({
richText={richText}
onPressReply={onPressReply}
logContext="PostThreadItem"
+ threadgateRecord={threadgateRecord}
/>
@@ -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,
]}>
{niceDate(post.indexedAt)}
-
+ {isRootPost && (
+
+ )}
{needsTranslation && (
<>
·
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index 0fef4c5a..e90e8b88 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
+ 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 (
@@ -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}>
-
+
{richText.text ? (
Reply to a blocked post
} else if (notFound) {
- label = Reply to an unknown post
+ label = Reply to a post
} 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 9676eff1..0920026f 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}
/>
>
)
@@ -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 6c82ec8c..b293b0df 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
@@ -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 (
@@ -383,20 +485,92 @@ let PostDropdownBtn = ({
{_(msg`Mute words & tags`)}
-
- {!isAuthor && !isPostHidden && (
-
- {_(msg`Hide post`)}
-
-
- )}
>
)}
+ {hasSession &&
+ (canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
+ <>
+
+
+ {canHidePostForMe && (
+
+
+ {isReply
+ ? _(msg`Hide reply for me`)
+ : _(msg`Hide post for me`)}
+
+
+
+ )}
+ {canHideReplyForEveryone && (
+ hideReplyConfirmControl.open()
+ }>
+
+ {isReplyHiddenByThreadgate
+ ? _(msg`Show reply for everyone`)
+ : _(msg`Hide reply for everyone`)}
+
+
+
+ )}
+
+ {canDetachQuote && (
+ quotePostDetachConfirmControl.open()
+ }>
+
+ {quoteEmbed.isDetached
+ ? _(msg`Re-attach quote`)
+ : _(msg`Detach quote`)}
+
+
+
+ )}
+
+ >
+ )}
+
{hasSession && (
<>
@@ -412,13 +586,34 @@ let PostDropdownBtn = ({
)}
{isAuthor && (
-
- {_(msg`Delete post`)}
-
-
+ <>
+
+
+ {_(msg`Edit interaction settings`)}
+
+
+
+
+ {_(msg`Delete post`)}
+
+
+ >
)}
>
@@ -439,8 +634,10 @@ let PostDropdownBtn = ({
@@ -479,6 +676,33 @@ let PostDropdownBtn = ({
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
+
+
+
+
+
+
)
}
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index ad586384..0cfa3fc4 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
@@ -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)}
/>
@@ -344,6 +348,7 @@ let PostCtrls = ({
style={{padding: 5}}
hitSlop={POST_CTRL_HITSLOP}
timestamp={post.indexedAt}
+ threadgateRecord={threadgateRecord}
/>
{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 d49cda44..5994b7ef 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 = ({
diff --git a/src/view/com/util/post-ctrls/RepostButton.web.tsx b/src/view/com/util/post-ctrls/RepostButton.web.tsx
index 17ab736c..9a8776b9 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 = ({
- {_(msg`Quote post`)}
+
+ {embeddingDisabled
+ ? _(msg`Quote posts disabled`)
+ : _(msg`Quote post`)}
+
diff --git a/src/view/com/util/post-embeds/QuoteEmbed.tsx b/src/view/com/util/post-embeds/QuoteEmbed.tsx
index 20c05b69..192aea70 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({
)
+ } else if (AppBskyEmbedRecord.isViewDetached(embed.record)) {
+ const isViewerOwner = currentAccount?.did
+ ? embed.record.uri.includes(currentAccount.did)
+ : false
+ return (
+
+
+
+ {isViewerOwner ? (
+ Removed by you
+ ) : (
+ Removed by author
+ )}
+
+
+ )
}
return null
}
diff --git a/src/view/screens/DebugMod.tsx b/src/view/screens/DebugMod.tsx
index 7d0d2fb0..9c609348 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}
/>
)
}
diff --git a/yarn.lock b/yarn.lock
index 995c548b..da842c89 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -72,10 +72,10 @@
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
-"@atproto/api@^0.13.0":
- version "0.13.0"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
- integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
+"@atproto/api@0.13.2":
+ version "0.13.2"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
+ integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"
@@ -85,10 +85,10 @@
multiformats "^9.9.0"
tlds "^1.234.0"
-"@atproto/api@^0.13.2":
- version "0.13.2"
- resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
- integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
+"@atproto/api@^0.13.0":
+ version "0.13.0"
+ resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
+ integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
dependencies:
"@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1"