Detached QPs and hidden replies (#4878)

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

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 476 B

View File

@ -52,7 +52,7 @@
"open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.13.2", "@atproto/api": "0.13.2",
"@bam.tech/react-native-image-resizer": "^3.0.4", "@bam.tech/react-native-image-resizer": "^3.0.4",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
"@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",

View File

@ -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 ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 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 {TestCtrls} from '#/view/com/testing/TestCtrls'
import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
@ -122,21 +123,23 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<TourProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <TourProvider>
<GestureHandlerRootView <ProgressGuideProvider>
style={s.h100pct}> <GestureHandlerRootView
<TestCtrls /> style={s.h100pct}>
<Shell /> <TestCtrls />
</GestureHandlerRootView> <Shell />
</ProgressGuideProvider> </GestureHandlerRootView>
</TourProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </TourProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
</LoggedOutViewProvider> </LoggedOutViewProvider>
</ModerationOptsProvider> </ModerationOptsProvider>

View File

@ -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 ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 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 {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web' import {ToastContainer} from '#/view/com/util/Toast.web'
@ -105,19 +106,21 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<TourProvider> <SafeAreaProvider>
<ProgressGuideProvider> <TourProvider>
<Shell /> <ProgressGuideProvider>
</ProgressGuideProvider> <Shell />
</TourProvider> </ProgressGuideProvider>
</SafeAreaProvider> </TourProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider> </SelectedFeedProvider>
</LoggedOutViewProvider> </LoggedOutViewProvider>
</ModerationOptsProvider> </ModerationOptsProvider>

View File

@ -13,6 +13,15 @@ import {
} from '#/components/moderation/ModerationDetailsDialog' } from '#/components/moderation/ModerationDetailsDialog'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export type AppModerationCause =
| ModerationCause
| {
type: 'reply-hidden'
source: {type: 'user'; did: string}
priority: 6
downgraded?: boolean
}
export type CommonProps = { export type CommonProps = {
size?: 'sm' | 'lg' size?: 'sm' | 'lg'
} }
@ -40,7 +49,7 @@ export function Row({
} }
export type LabelProps = { export type LabelProps = {
cause: ModerationCause cause: AppModerationCause
disableDetailsDialog?: boolean disableDetailsDialog?: boolean
noBg?: boolean noBg?: boolean
} & CommonProps } & CommonProps

View File

@ -1,39 +1,34 @@
import React from 'react' import React from 'react'
import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedPost,
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
BskyAgent,
} from '@atproto/api' } from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {createThreadgate} from '#/lib/api'
import {until} from '#/lib/async/until'
import {HITSLOP_10} from '#/lib/constants' import {HITSLOP_10} from '#/lib/constants'
import {makeListLink, makeProfileLink} from '#/lib/routes/links' import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import { import {
ThreadgateSetting, ThreadgateAllowUISetting,
threadgateViewToSettings, threadgateViewToAllowUISetting,
} from '#/state/queries/threadgate' } 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 {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {useDialogControl} 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 {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe'
import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
import {InlineLinkText} from '#/components/Link'
import {Text} from '#/components/Typography' 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' import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil'
interface WhoCanReplyProps { interface WhoCanReplyProps {
@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
const t = useTheme() const t = useTheme()
const infoDialogControl = useDialogControl() const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl() const editDialogControl = useDialogControl()
const agent = useAgent()
const queryClient = useQueryClient()
const settings = React.useMemo( /*
() => threadgateViewToSettings(post.threadgate), * `WhoCanReply` is only used for root posts atm, in case this changes
[post], * unexpectedly, we should check to make sure it's for sure the root URI.
) */
const isRootPost = !('reply' in post.record) 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) { const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({
return null postUri: post.uri,
} rootPostUri: rootUri,
if (!settings.length && !isThreadAuthor) { })
return null
}
const isEverybody = settings.length === 0 const anyoneCanReply =
const isNobody = !!settings.find(gate => gate.type === 'nobody') settings.length === 1 && settings[0].type === 'everybody'
const description = isEverybody const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
const description = anyoneCanReply
? _(msg`Everybody can reply`) ? _(msg`Everybody can reply`)
: isNobody : noOneCanReply
? _(msg`Replies disabled`) ? _(msg`Replies disabled`)
: _(msg`Some people can reply`) : _(msg`Some people can reply`)
const onPressEdit = () => { const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) { if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss() 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 ( return (
<> <>
<Button <Button
label={ label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
} }
onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} onPress={onPressOpen}
{...(isThreadAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}
hitSlop={HITSLOP_10}> hitSlop={HITSLOP_10}>
{({hovered}) => ( {({hovered}) => (
<View style={[a.flex_row, a.align_center, a.gap_xs, style]}> <View style={[a.flex_row, a.align_center, a.gap_xs, style]}>
@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
]}> ]}>
{description} {description}
</Text> </Text>
{isThreadAuthor && ( {isThreadAuthor && (
<PencilLine width={12} fill={t.palette.primary_500} /> <PencilLine width={12} fill={t.palette.primary_500} />
)} )}
</View> </View>
)} )}
</Button> </Button>
<WhoCanReplyDialog
control={infoDialogControl} {isThreadAuthor ? (
post={post} <PostInteractionSettingsDialog
settings={settings} postUri={post.uri}
/> rootPostUri={rootUri}
{isThreadAuthor && (
<ThreadgateEditorDialog
control={editDialogControl} control={editDialogControl}
threadgate={settings} initialThreadgateView={post.threadgate}
onConfirm={onEditConfirm} />
) : (
<WhoCanReplyDialog
control={infoDialogControl}
post={post}
settings={settings}
embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
/> />
)} )}
</> </>
@ -174,7 +148,7 @@ function Icon({
}: { }: {
color: string color: string
width?: number width?: number
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
}) { }) {
const isEverybody = settings.length === 0 const isEverybody = settings.length === 0
const isNobody = !!settings.find(gate => gate.type === 'nobody') const isNobody = !!settings.find(gate => gate.type === 'nobody')
@ -186,79 +160,84 @@ function WhoCanReplyDialog({
control, control,
post, post,
settings, settings,
embeddingDisabled,
}: { }: {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
}) { embeddingDisabled: boolean
return (
<Dialog.Outer control={control}>
<Dialog.Handle />
<WhoCanReplyDialogInner post={post} settings={settings} />
</Dialog.Outer>
)
}
function WhoCanReplyDialogInner({
post,
settings,
}: {
post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[]
}) { }) {
const {_} = useLingui() const {_} = useLingui()
return ( return (
<Dialog.ScrollableInner <Dialog.Outer control={control}>
label={_(msg`Who can reply dialog`)} <Dialog.Handle />
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> <Dialog.ScrollableInner
<View style={[a.gap_sm]}> label={_(msg`Dialog: adjust who can interact with this post`)}
<Text style={[a.font_bold, a.text_xl]}> style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<Trans>Who can reply?</Trans> <View style={[a.gap_sm]}>
</Text> <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Rules post={post} settings={settings} /> <Trans>Who can interact with this post?</Trans>
</View> </Text>
</Dialog.ScrollableInner> <Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View>
</Dialog.ScrollableInner>
</Dialog.Outer>
) )
} }
function Rules({ function Rules({
post, post,
settings, settings,
embeddingDisabled,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
settings: ThreadgateSetting[] settings: ThreadgateAllowUISetting[]
embeddingDisabled: boolean
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<Text <>
style={[ <Text
a.text_md, style={[
a.leading_tight, a.text_sm,
a.flex_wrap, a.leading_snug,
t.atoms.text_contrast_medium, a.flex_wrap,
]}> t.atoms.text_contrast_medium,
{!settings.length ? ( ]}>
<Trans>Everybody can reply</Trans> {settings[0].type === 'everybody' ? (
) : settings[0].type === 'nobody' ? ( <Trans>Everybody can reply to this post.</Trans>
<Trans>Replies to this thread are disabled</Trans> ) : settings[0].type === 'nobody' ? (
) : ( <Trans>Replies to this post are disabled.</Trans>
<Trans> ) : (
Only{' '} <Trans>
{settings.map((rule, i) => ( Only{' '}
<> {settings.map((rule, i) => (
<Rule <React.Fragment key={`rule-${i}`}>
key={`rule-${i}`} <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
rule={rule} <Separator i={i} length={settings.length} />
post={post} </React.Fragment>
lists={post.threadgate!.lists} ))}{' '}
/> can reply.
<Separator key={`sep-${i}`} i={i} length={settings.length} /> </Trans>
</> )}{' '}
))}{' '} </Text>
can reply {embeddingDisabled && (
</Trans> <Text
style={[
a.text_sm,
a.leading_snug,
a.flex_wrap,
t.atoms.text_contrast_medium,
]}>
<Trans>No one but the author can quote this post.</Trans>
</Text>
)} )}
</Text> </>
) )
} }
@ -267,11 +246,10 @@ function Rule({
post, post,
lists, lists,
}: { }: {
rule: ThreadgateSetting rule: ThreadgateAllowUISetting
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
lists: AppBskyGraphDefs.ListViewBasic[] | undefined lists: AppBskyGraphDefs.ListViewBasic[] | undefined
}) { }) {
const t = useTheme()
if (rule.type === 'mention') { if (rule.type === 'mention') {
return <Trans>mentioned users</Trans> return <Trans>mentioned users</Trans>
} }
@ -279,12 +257,12 @@ function Rule({
return ( return (
<Trans> <Trans>
users followed by{' '} users followed by{' '}
<TextLink <InlineLinkText
type="sm" label={`@${post.author.handle}`}
href={makeProfileLink(post.author)} to={makeProfileLink(post.author)}
text={`@${post.author.handle}`} style={[a.text_sm, a.leading_snug]}>
style={{color: t.palette.primary_500}} @{post.author.handle}
/> </InlineLinkText>
</Trans> </Trans>
) )
} }
@ -294,12 +272,12 @@ function Rule({
const listUrip = new AtUri(list.uri) const listUrip = new AtUri(list.uri)
return ( return (
<Trans> <Trans>
<TextLink <InlineLinkText
type="sm" label={list.name}
href={makeListLink(listUrip.hostname, listUrip.rkey)} to={makeListLink(listUrip.hostname, listUrip.rkey)}
text={list.name} style={[a.text_sm, a.leading_snug]}>
style={{color: t.palette.primary_500}} {list.name}
/>{' '} </InlineLinkText>{' '}
members members
</Trans> </Trans>
) )
@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) {
} }
return <>, </> 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,
}),
)
}

View File

@ -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 (
<Dialog.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsForm {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer>
)
}
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 (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
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<AppBskyFeedPostgate.Record>()
const [editedAllowUISettings, setEditedAllowUISettings] =
React.useState<ThreadgateAllowUISetting[]>()
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 (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsForm
replySettingsDisabled={!isThreadgateOwnedByViewer}
isSaving={isSaving}
onSave={onSave}
postgate={postgateValue}
onChangePostgate={setEditedPostgate}
threadgateAllowUISettings={allowUIValue}
onChangeThreadgateAllowUISettings={setEditedAllowUISettings}
/>
)}
</Dialog.ScrollableInner>
)
}
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 (
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
</Text>
<View style={[a.gap_lg]}>
<Text style={[a.text_md]}>
<Trans>Customize who can interact with this post.</Trans>
</Text>
<Divider />
<View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Quote settings</Trans>
</Text>
<Toggle.Item
name="quoteposts"
type="checkbox"
label={
quotesEnabled
? _(msg`Click to disable quote posts of this post.`)
: _(msg`Click to enable quote posts of this post.`)
}
value={quotesEnabled}
onChange={onChangeQuotesEnabled}
style={[, a.justify_between, a.pt_xs]}>
<Text style={[t.atoms.text_contrast_medium]}>
{quotesEnabled ? (
<Trans>Quote posts enabled</Trans>
) : (
<Trans>Quote posts disabled</Trans>
)}
</Text>
<Toggle.Switch />
</Toggle.Item>
</View>
<Divider />
{replySettingsDisabled && (
<View
style={[
a.px_md,
a.py_sm,
a.rounded_sm,
a.flex_row,
a.align_center,
a.gap_sm,
t.atoms.bg_contrast_25,
]}>
<CircleInfo fill={t.atoms.text_contrast_low.color} />
<Text
style={[
a.flex_1,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Reply settings are chosen by the author of the thread
</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans>
</Text>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Allow replies from:</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'everybody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={noOneCanReply}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
{!noOneCanReply && (
<>
<Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'mention',
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.find(
v => v.type === 'following',
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!threadgateAllowUISettings.find(
v => 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}
</View>
</>
)}
</View>
</View>
</View>
<Button
label={_(msg`Save`)}
onPress={onSave}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
</View>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
disabled,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
disabled?: boolean
}) {
const t = useTheme()
return (
<Button
disabled={disabled}
onPress={onPress}
label={label}
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}
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])
}

View File

@ -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 (
<Dialog.Outer control={control}>
<Dialog.Handle />
<DialogContent
seedThreadgate={threadgate}
onChange={onChange}
onConfirm={onConfirm}
/>
</Dialog.Outer>
)
}
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 (
<Dialog.ScrollableInner
label={_(msg`Choose who can reply`)}
style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans>
</Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans>
</Text>
<View style={[a.flex_row, a.gap_sm]}>
<Selectable
label={_(msg`Everybody`)}
isSelected={draft.length === 0}
onPress={onPressEverybody}
style={{flex: 1}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody}
style={{flex: 1}}
/>
</View>
<Text style={a.mt_md}>
<Trans>Or combine these options:</Trans>
</Text>
<View style={[a.gap_sm]}>
<Selectable
label={_(msg`Mentioned users`)}
isSelected={!!draft.find(v => v.type === 'mention')}
onPress={() => onPressAudience({type: 'mention'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={!!draft.find(v => v.type === 'following')}
onPress={() => onPressAudience({type: 'following'})}
/>
{lists && lists.length > 0
? lists.map(list => (
<Selectable
key={list.uri}
label={_(msg`Users in "${list.name}"`)}
isSelected={
!!draft.find(v => 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}
</View>
</View>
<Button
label={doneLabel}
onPress={() => {
control.close()
onConfirm?.(draft)
}}
onAccessibilityEscape={control.close}
color="primary"
size="medium"
variant="solid"
style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText>
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
)
}
function Selectable({
label,
isSelected,
onPress,
style,
}: {
label: string
isSelected: boolean
onPress: () => void
style?: StyleProp<ViewStyle>
}) {
const t = useTheme()
return (
<Button
onPress={onPress}
label={label}
accessibilityHint="Select this option"
accessibilityRole="checkbox"
aria-checked={isSelected}
accessibilityState={{
checked: isSelected,
}}
style={a.flex_1}>
{({hovered, focused}) => (
<View
style={[
a.flex_1,
a.flex_row,
a.align_center,
a.justify_between,
a.rounded_sm,
a.p_md,
{height: 40}, // for consistency with checkmark icon visible or not
t.atoms.bg_contrast_50,
(hovered || focused) && t.atoms.bg_contrast_100,
isSelected && {
backgroundColor: t.palette.primary_100,
},
style,
]}>
<Text style={[a.text_sm, isSelected && a.font_semibold]}>
{label}
</Text>
{isSelected ? (
<Check size="sm" fill={t.palette.primary_500} />
) : (
<View />
)}
</View>
)}
</Button>
)
}

View File

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

View File

@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause
import {makeProfileLink} from '#/lib/routes/links' import {makeProfileLink} from '#/lib/routes/links'
import {listUriToHref} from '#/lib/strings/url-helpers' import {listUriToHref} from '#/lib/strings/url-helpers'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider' import {Divider} from '#/components/Divider'
import {InlineLinkText} from '#/components/Link' import {InlineLinkText} from '#/components/Link'
import {AppModerationCause} from '#/components/Pills'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog'
export interface ModerationDetailsDialogProps { export interface ModerationDetailsDialogProps {
control: Dialog.DialogOuterProps['control'] control: Dialog.DialogOuterProps['control']
modcause?: ModerationCause modcause?: ModerationCause | AppModerationCause
} }
export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) {
@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const desc = useModerationCauseDescription(modcause) const desc = useModerationCauseDescription(modcause)
const {currentAccount} = useSession()
let name let name
let description let description
@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({
} else if (modcause.type === 'hidden') { } else if (modcause.type === 'hidden') {
name = _(msg`Post Hidden by You`) name = _(msg`Post Hidden by You`)
description = _(msg`You have hidden this post.`) 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') { } else if (modcause.type === 'label') {
name = desc.name name = desc.name
description = desc.description description = desc.description
@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name} {name}
</Text> </Text>
<Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> <Text style={[t.atoms.text, a.text_md, a.leading_snug]}>
{description} {description}
</Text> </Text>
{modcause?.type === 'label' && ( {modcause?.type === 'label' && (
<> <View style={[a.pt_lg]}>
<Divider /> <Divider />
<Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}>
{modcause.source.type === 'user' ? ( {modcause.source.type === 'user' ? (
@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({
</Trans> </Trans>
)} )}
</Text> </Text>
</> </View>
)} )}
{isNative && <View style={{height: 40}} />} {isNative && <View style={{height: 40}} />}

View File

@ -1,6 +1,6 @@
import React from 'react' import React from 'react'
import {StyleProp, ViewStyle} from 'react-native' import {StyleProp, ViewStyle} from 'react-native'
import {ModerationUI} from '@atproto/api' import {ModerationCause, ModerationUI} from '@atproto/api'
import {getModerationCauseKey} from '#/lib/moderation' import {getModerationCauseKey} from '#/lib/moderation'
import * as Pills from '#/components/Pills' import * as Pills from '#/components/Pills'
@ -9,13 +9,15 @@ export function PostAlerts({
modui, modui,
size = 'sm', size = 'sm',
style, style,
additionalCauses,
}: { }: {
modui: ModerationUI modui: ModerationUI
size?: Pills.CommonProps['size'] size?: Pills.CommonProps['size']
includeMute?: boolean includeMute?: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
additionalCauses?: ModerationCause[] | Pills.AppModerationCause[]
}) { }) {
if (!modui.alert && !modui.inform) { if (!modui.alert && !modui.inform && !additionalCauses?.length) {
return null return null
} }
@ -37,6 +39,14 @@ export function PostAlerts({
noBg={size === 'sm'} noBg={size === 'sm'}
/> />
))} ))}
{additionalCauses?.map(cause => (
<Pills.Label
key={getModerationCauseKey(cause)}
cause={cause}
size={size}
noBg={size === 'sm'}
/>
))}
</Pills.Row> </Pills.Row>
) )
} }

View File

@ -3,7 +3,7 @@ import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedThreadgate, AppBskyFeedPostgate,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
RichText, RichText,
@ -11,7 +11,13 @@ import {
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {logger} from '#/logger' 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 {isNetworkError} from 'lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from 'lib/strings/rich-text-manip'
import {isNative} from 'platform/detection' import {isNative} from 'platform/detection'
@ -44,7 +50,8 @@ interface PostOpts {
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: ImageModel[] images?: ImageModel[]
labels?: string[] labels?: string[]
threadgate?: ThreadgateSetting[] threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void onStateChange?: (state: string) => void
langs?: string[] langs?: string[]
} }
@ -232,7 +239,9 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
labels, labels,
}) })
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`) logger.error(`Failed to create post`, {
safeMessage: e.message,
})
if (isNetworkError(e)) { if (isNetworkError(e)) {
throw new Error( throw new Error(
'Post failed to upload. Please check your Internet connection and try again.', '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 { if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
// TODO: this needs to be batch-created with the post! try {
if (opts.threadgate?.length) { // TODO: this needs to be batch-created with the post!
await createThreadgate(agent, res.uri, opts.threadgate) 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 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(),
},
})
}

View File

@ -107,6 +107,11 @@ export async function extractBskyMeta(
return meta return meta
} }
export class EmbeddingDisabledError extends Error {
constructor() {
super('Embedding is disabled for this record')
}
}
export async function getPostAsQuote( export async function getPostAsQuote(
getPost: ReturnType<typeof useGetPost>, getPost: ReturnType<typeof useGetPost>,
url: string, url: string,
@ -115,6 +120,9 @@ export async function getPostAsQuote(
const [_0, user, _1, rkey] = url.split('/').filter(Boolean) const [_0, user, _1, rkey] = url.split('/').filter(Boolean)
const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey) const uri = makeRecordUri(user, 'app.bsky.feed.post', rkey)
const post = await getPost({uri: uri}) const post = await getPost({uri: uri})
if (post.viewer?.embeddingDisabled) {
throw new EmbeddingDisabledError()
}
return { return {
uri: post.uri, uri: post.uri,
cid: post.cid, cid: post.cid,

View File

@ -1,17 +1,20 @@
import { import {
ModerationCause,
ModerationUI,
InterpretedLabelValueDefinition,
LABELS,
AppBskyLabelerDefs, AppBskyLabelerDefs,
BskyAgent, BskyAgent,
InterpretedLabelValueDefinition,
LABELS,
ModerationCause,
ModerationOpts, ModerationOpts,
ModerationUI,
} from '@atproto/api' } from '@atproto/api'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' 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 = const source =
cause.source.type === 'labeler' cause.source.type === 'labeler'
? cause.source.did ? cause.source.did

View File

@ -8,11 +8,13 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {useLabelDefinitions} from '#/state/preferences' import {useLabelDefinitions} from '#/state/preferences'
import {useSession} from '#/state/session'
import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Props as SVGIconProps} from '#/components/icons/common' import {Props as SVGIconProps} from '#/components/icons/common'
import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash' import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/EyeSlash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning' import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
import {AppModerationCause} from '#/components/Pills'
import {useGlobalLabelStrings} from './useGlobalLabelStrings' import {useGlobalLabelStrings} from './useGlobalLabelStrings'
import {getDefinition, getLabelStrings} from './useLabelInfo' import {getDefinition, getLabelStrings} from './useLabelInfo'
@ -27,8 +29,9 @@ export interface ModerationCauseDescription {
} }
export function useModerationCauseDescription( export function useModerationCauseDescription(
cause: ModerationCause | undefined, cause: ModerationCause | AppModerationCause | undefined,
): ModerationCauseDescription { ): ModerationCauseDescription {
const {currentAccount} = useSession()
const {_, i18n} = useLingui() const {_, i18n} = useLingui()
const {labelDefs, labelers} = useLabelDefinitions() const {labelDefs, labelers} = useLabelDefinitions()
const globalLabelStrings = useGlobalLabelStrings() const globalLabelStrings = useGlobalLabelStrings()
@ -111,6 +114,18 @@ export function useModerationCauseDescription(
description: _(msg`You have hidden this post`), 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') { if (cause.type === 'label') {
const def = cause.labelDef || getDefinition(labelDefs, cause.label) const def = cause.labelDef || getDefinition(labelDefs, cause.label)
const strings = getLabelStrings(i18n.locale, globalLabelStrings, def) const strings = getLabelStrings(i18n.locale, globalLabelStrings, def)
@ -150,5 +165,13 @@ export function useModerationCauseDescription(
name: '', name: '',
description: ``, description: ``,
} }
}, [labelDefs, labelers, globalLabelStrings, cause, _, i18n.locale]) }, [
labelDefs,
labelers,
globalLabelStrings,
cause,
_,
i18n.locale,
currentAccount?.did,
])
} }

View File

@ -1,5 +1,9 @@
import {useEffect, useMemo, useState} from 'react' 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 {QueryClient} from '@tanstack/react-query'
import EventEmitter from 'eventemitter3' import EventEmitter from 'eventemitter3'
@ -16,6 +20,7 @@ export interface PostShadow {
likeUri: string | undefined likeUri: string | undefined
repostUri: string | undefined repostUri: string | undefined
isDeleted: boolean isDeleted: boolean
embed: AppBskyEmbedRecord.View | AppBskyEmbedRecordWithMedia.View | undefined
} }
export const POST_TOMBSTONE = Symbol('PostTombstone') export const POST_TOMBSTONE = Symbol('PostTombstone')
@ -87,8 +92,21 @@ function mergeShadow(
repostCount = Math.max(0, repostCount) 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({ return castAsShadow({
...post, ...post,
embed: embed || post.embed,
likeCount: likeCount, likeCount: likeCount,
repostCount: repostCount, repostCount: repostCount,
viewer: { viewer: {

View File

@ -16,7 +16,7 @@
* 3. Don't call this query's `refetch()` if you're trying to sync latest; call `checkUnread()` instead. * 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 {AppBskyActorDefs, AppBskyFeedDefs, AtUri} from '@atproto/api'
import { import {
InfiniteData, InfiniteData,
@ -27,6 +27,7 @@ import {
} from '@tanstack/react-query' } from '@tanstack/react-query'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import {useThreadgateHiddenReplyUris} from '#/state/threadgate-hidden-replies'
import {useModerationOpts} from '../../preferences/moderation-opts' import {useModerationOpts} from '../../preferences/moderation-opts'
import {STALE} from '..' import {STALE} from '..'
import { import {
@ -58,11 +59,18 @@ export function useNotificationFeedQuery(opts?: {
const moderationOpts = useModerationOpts() const moderationOpts = useModerationOpts()
const unreads = useUnreadNotificationsApi() const unreads = useUnreadNotificationsApi()
const enabled = opts?.enabled !== false const enabled = opts?.enabled !== false
const {uris: hiddenReplyUris} = useThreadgateHiddenReplyUris()
// false: force showing all notifications // false: force showing all notifications
// undefined: let the server decide // undefined: let the server decide
const priority = opts?.overridePriorityNotifications ? false : undefined const priority = opts?.overridePriorityNotifications ? false : undefined
const selectArgs = useMemo(() => {
return {
hiddenReplyUris,
}
}, [hiddenReplyUris])
const query = useInfiniteQuery< const query = useInfiniteQuery<
FeedPage, FeedPage,
Error, Error,
@ -101,20 +109,41 @@ export function useNotificationFeedQuery(opts?: {
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
enabled, enabled,
select(data: InfiniteData<FeedPage>) { select: useCallback(
// override 'isRead' using the first page's returned seenAt (data: InfiniteData<FeedPage>) => {
// we do this because the `markAllRead()` call above will const {hiddenReplyUris} = selectArgs
// 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)
}
}
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, // The server may end up returning an empty page, a page with too few items,

View File

@ -138,6 +138,7 @@ export function sortThread(
modCache: ThreadModerationCache, modCache: ThreadModerationCache,
currentDid: string | undefined, currentDid: string | undefined,
justPostedUris: Set<string>, justPostedUris: Set<string>,
threadgateRecordHiddenReplies: Set<string>,
): ThreadNode { ): ThreadNode {
if (node.type !== 'post') { if (node.type !== 'post') {
return node return node
@ -185,6 +186,14 @@ export function sortThread(
return 1 // current account's reply 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 aBlur = Boolean(modCache.get(a)?.ui('contentList').blur)
const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur) const bBlur = Boolean(modCache.get(b)?.ui('contentList').blur)
if (aBlur !== bBlur) { if (aBlur !== bBlur) {
@ -222,7 +231,14 @@ export function sortThread(
return b.post.indexedAt.localeCompare(a.post.indexedAt) return b.post.indexedAt.localeCompare(a.post.indexedAt)
}) })
node.replies.forEach(reply => node.replies.forEach(reply =>
sortThread(reply, opts, modCache, currentDid, justPostedUris), sortThread(
reply,
opts,
modCache,
currentDid,
justPostedUris,
threadgateRecordHiddenReplies,
),
) )
} }
return node return node

View File

@ -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( export function usePostLikeMutationQueue(
post: Shadow<AppBskyFeedDefs.PostView>, post: Shadow<AppBskyFeedDefs.PostView>,
logContext: LogEvents['post:like']['logContext'] & logContext: LogEvents['post:like']['logContext'] &

View File

@ -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<AppBskyFeedPostgate.Record | undefined> {
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<AppBskyFeedPostgate.Record | undefined>,
) {
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<AppBskyFeedDefs.PostView['embed']>()
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'}],
})
}
}
})
},
})
}

View File

@ -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<AppBskyFeedPostgate.Record> & {
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<AppBskyFeedPostgate.Record>,
) {
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'},
}

View File

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

View File

@ -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<AppBskyFeedThreadgate.Record | null> {
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<AppBskyFeedThreadgate.Record | undefined>,
) {
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)
}
},
})
}

View File

@ -0,0 +1,6 @@
export type ThreadgateAllowUISetting =
| {type: 'everybody'}
| {type: 'nobody'}
| {type: 'mention'}
| {type: 'following'}
| {type: 'list'; list: unknown}

View File

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

View File

@ -0,0 +1,69 @@
import React from 'react'
type StateContext = {
uris: Set<string>
recentlyUnhiddenUris: Set<string>
}
type ApiContext = {
addHiddenReplyUri: (uri: string) => void
removeHiddenReplyUri: (uri: string) => void
}
const StateContext = React.createContext<StateContext>({
uris: new Set(),
recentlyUnhiddenUris: new Set(),
})
const ApiContext = React.createContext<ApiContext>({
addHiddenReplyUri: () => {},
removeHiddenReplyUri: () => {},
})
export function Provider({children}: {children: React.ReactNode}) {
const [uris, setHiddenReplyUris] = React.useState<Set<string>>(new Set())
const [recentlyUnhiddenUris, setRecentlyUnhiddenUris] = React.useState<
Set<string>
>(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 (
<ApiContext.Provider value={apiCtx}>
<StateContext.Provider value={stateCtx}>{children}</StateContext.Provider>
</ApiContext.Provider>
)
}
export function useThreadgateHiddenReplyUris() {
return React.useContext(StateContext)
}
export function useThreadgateHiddenReplyUrisAPI() {
return React.useContext(ApiContext)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,10 +72,10 @@
resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
"@atproto/api@^0.13.0": "@atproto/api@0.13.2":
version "0.13.0" version "0.13.2"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"
@ -85,10 +85,10 @@
multiformats "^9.9.0" multiformats "^9.9.0"
tlds "^1.234.0" tlds "^1.234.0"
"@atproto/api@^0.13.2": "@atproto/api@^0.13.0":
version "0.13.2" version "0.13.0"
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
dependencies: dependencies:
"@atproto/common-web" "^0.3.0" "@atproto/common-web" "^0.3.0"
"@atproto/lexicon" "^0.4.1" "@atproto/lexicon" "^0.4.1"