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,6 +123,7 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
@ -137,6 +139,7 @@ function InnerApp() {
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </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,6 +106,7 @@ function InnerApp() {
<ModerationOptsProvider> <ModerationOptsProvider>
<LoggedOutViewProvider> <LoggedOutViewProvider>
<SelectedFeedProvider> <SelectedFeedProvider>
<HiddenRepliesProvider>
<UnreadNotifsProvider> <UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider> <BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider> <MutedThreadsProvider>
@ -118,6 +120,7 @@ function InnerApp() {
</MutedThreadsProvider> </MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider> </BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider> </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>
{isThreadAuthor ? (
<PostInteractionSettingsDialog
postUri={post.uri}
rootPostUri={rootUri}
control={editDialogControl}
initialThreadgateView={post.threadgate}
/>
) : (
<WhoCanReplyDialog <WhoCanReplyDialog
control={infoDialogControl} control={infoDialogControl}
post={post} post={post}
settings={settings} settings={settings}
/> embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)}
{isThreadAuthor && (
<ThreadgateEditorDialog
control={editDialogControl}
threadgate={settings}
onConfirm={onEditConfirm}
/> />
)} )}
</> </>
@ -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.Outer control={control}>
<Dialog.Handle />
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Who can reply dialog`)} label={_(msg`Dialog: adjust who can interact with this post`)}
style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}>
<View style={[a.gap_sm]}> <View style={[a.gap_sm]}>
<Text style={[a.font_bold, a.text_xl]}> <Text style={[a.font_bold, a.text_xl, a.pb_sm]}>
<Trans>Who can reply?</Trans> <Trans>Who can interact with this post?</Trans>
</Text> </Text>
<Rules post={post} settings={settings} /> <Rules
post={post}
settings={settings}
embeddingDisabled={embeddingDisabled}
/>
</View> </View>
</Dialog.ScrollableInner> </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 <Text
style={[ style={[
a.text_md, a.text_sm,
a.leading_tight, a.leading_snug,
a.flex_wrap, a.flex_wrap,
t.atoms.text_contrast_medium, t.atoms.text_contrast_medium,
]}> ]}>
{!settings.length ? ( {settings[0].type === 'everybody' ? (
<Trans>Everybody can reply</Trans> <Trans>Everybody can reply to this post.</Trans>
) : settings[0].type === 'nobody' ? ( ) : settings[0].type === 'nobody' ? (
<Trans>Replies to this thread are disabled</Trans> <Trans>Replies to this post are disabled.</Trans>
) : ( ) : (
<Trans> <Trans>
Only{' '} Only{' '}
{settings.map((rule, i) => ( {settings.map((rule, i) => (
<> <React.Fragment key={`rule-${i}`}>
<Rule <Rule rule={rule} post={post} lists={post.threadgate!.lists} />
key={`rule-${i}`} <Separator i={i} length={settings.length} />
rule={rule} </React.Fragment>
post={post}
lists={post.threadgate!.lists}
/>
<Separator key={`sep-${i}`} i={i} length={settings.length} />
</>
))}{' '} ))}{' '}
can reply can reply.
</Trans> </Trans>
)} )}{' '}
</Text> </Text>
{embeddingDisabled && (
<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>
)}
</>
) )
} }
@ -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) {
} }
} }
if (opts.threadgate.some(tg => tg.type !== 'everybody')) {
try { try {
// TODO: this needs to be batch-created with the post! // TODO: this needs to be batch-created with the post!
if (opts.threadgate?.length) { await writeThreadgateRecord({
await createThreadgate(agent, res.uri, opts.threadgate) agent,
} postUri: res.uri,
threadgate: createThreadgateRecord({
post: res.uri,
allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
}),
})
} catch (e: any) { } catch (e: any) {
console.error(`Failed to create threadgate: ${e.toString()}`) logger.error(`Failed to create threadgate`, {
context: 'composer',
safeMessage: e.message,
})
throw new Error( throw new Error(
'Post reply-controls failed to be set. Your post was created but anyone can reply to it.', '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.',
)
}
}
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,7 +109,10 @@ export function useNotificationFeedQuery(opts?: {
initialPageParam: undefined, initialPageParam: undefined,
getNextPageParam: lastPage => lastPage.cursor, getNextPageParam: lastPage => lastPage.cursor,
enabled, enabled,
select(data: InfiniteData<FeedPage>) { select: useCallback(
(data: InfiniteData<FeedPage>) => {
const {hiddenReplyUris} = selectArgs
// override 'isRead' using the first page's returned seenAt // override 'isRead' using the first page's returned seenAt
// we do this because the `markAllRead()` call above will // we do this because the `markAllRead()` call above will
// mark subsequent pages as read prematurely // mark subsequent pages as read prematurely
@ -113,8 +124,26 @@ export function useNotificationFeedQuery(opts?: {
} }
} }
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 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 => {
if (err instanceof EmbeddingDisabledError) {
setError(_(msg`This post's author has disabled quote posts.`))
} else {
logger.error('Failed to fetch post for quote embedding', { logger.error('Failed to fetch post for quote embedding', {
message: err.toString(), 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>
{isRootPost && (
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> <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,16 +485,88 @@ 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>
</Menu.Group>
</>
)}
{!isAuthor && !isPostHidden && ( {hasSession &&
(canHideReplyForEveryone || canDetachQuote || canHidePostForMe) && (
<>
<Menu.Divider />
<Menu.Group>
{canHidePostForMe && (
<Menu.Item <Menu.Item
testID="postDropdownHideBtn" testID="postDropdownHideBtn"
label={_(msg`Hide post`)} label={
isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)
}
onPress={hidePromptControl.open}> onPress={hidePromptControl.open}>
<Menu.ItemText>{_(msg`Hide post`)}</Menu.ItemText> <Menu.ItemText>
{isReply
? _(msg`Hide reply for me`)
: _(msg`Hide post for me`)}
</Menu.ItemText>
<Menu.ItemIcon icon={EyeSlash} position="right" /> <Menu.ItemIcon icon={EyeSlash} position="right" />
</Menu.Item> </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> </Menu.Group>
</> </>
)} )}
@ -412,6 +586,26 @@ let PostDropdownBtn = ({
)} )}
{isAuthor && ( {isAuthor && (
<>
<Menu.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
onPress={postInteractionSettingsDialogControl.open}
{...(isAuthor
? Platform.select({
web: {
onHoverIn: prefetchPostInteractionSettings,
},
native: {
onPressIn: prefetchPostInteractionSettings,
},
})
: {})}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item <Menu.Item
testID="postDropdownDeleteBtn" testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)} label={_(msg`Delete post`)}
@ -419,6 +613,7 @@ let PostDropdownBtn = ({
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> <Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item> </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"