parent
56ab5e177f
commit
6616a6467e
|
@ -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 |
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
@ -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])
|
||||||
|
}
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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',
|
||||||
|
})
|
|
@ -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}} />}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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'] &
|
||||||
|
|
|
@ -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'}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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'},
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
export type ThreadgateAllowUISetting =
|
||||||
|
| {type: 'everybody'}
|
||||||
|
| {type: 'nobody'}
|
||||||
|
| {type: 'mention'}
|
||||||
|
| {type: 'following'}
|
||||||
|
| {type: 'list'; list: unknown}
|
|
@ -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 || [],
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]}>·</Text>
|
<Text style={[a.text_sm, pal.textLight]}>·</Text>
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -807,6 +807,7 @@ function MockPostFeedItem({
|
||||||
showReplyTo={false}
|
showReplyTo={false}
|
||||||
reason={undefined}
|
reason={undefined}
|
||||||
feedContext={''}
|
feedContext={''}
|
||||||
|
rootPost={post}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
16
yarn.lock
16
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in New Issue