Integrate postgates into ThreadgateEditor dialog

This commit is contained in:
Eric Bailey
2024-08-09 15:04:01 -05:00
parent f9e988c5fb
commit 26e7d31557
9 changed files with 424 additions and 167 deletions
+100 -32
View File
@@ -3,6 +3,7 @@ import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetPostThread, AppBskyFeedGetPostThread,
AppBskyFeedPostgate,
AppBskyGraphDefs, AppBskyGraphDefs,
AtUri, AtUri,
BskyAgent, BskyAgent,
@@ -17,11 +18,13 @@ import {makeListLink, makeProfileLink} from '#/lib/routes/links'
import {logger} from '#/logger' 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 {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread'
import {updateThreadgateAllow} from '#/state/queries/threadgate' import {useWritePostgateMutation} from '#/state/queries/postgate'
import {threadgateRecordQueryKeyRoot} from '#/state/queries/threadgate' import {embeddingRules} from '#/state/queries/postgate/util'
import { import {
ThreadgateAllowUISetting, ThreadgateAllowUISetting,
threadgateRecordQueryKeyRoot,
threadgateViewToAllowUISetting, threadgateViewToAllowUISetting,
updateThreadgateAllow,
} from '#/state/queries/threadgate' } from '#/state/queries/threadgate'
import {useAgent} from '#/state/session' import {useAgent} from '#/state/session'
import * as Toast from 'view/com/util/Toast' import * as Toast from 'view/com/util/Toast'
@@ -41,35 +44,47 @@ interface WhoCanReplyProps {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean isThreadAuthor: boolean
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
postgate: AppBskyFeedPostgate.Record
} }
export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { export function WhoCanReply({
post,
isThreadAuthor,
style,
postgate: initialPostgate,
}: WhoCanReplyProps) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const infoDialogControl = useDialogControl() const infoDialogControl = useDialogControl()
const editDialogControl = useDialogControl() const editDialogControl = useDialogControl()
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [isSaving, setIsSaving] = React.useState(false)
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const settings = React.useMemo( const [postgate, setPostgate] = React.useState(initialPostgate)
() => threadgateViewToAllowUISetting(post.threadgate), // TODO test if we get weird data back
[post], const [settings, setSettings] = React.useState(
threadgateViewToAllowUISetting(post.threadgate),
) )
const isRootPost = !('reply' in post.record)
if (!isRootPost) { const anyoneCanReply =
return null settings.length === 1 && settings[0].type === 'everybody'
} const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody'
if (!settings.length && !isThreadAuthor) { const anyoneCanQuote =
return null !postgate.quotepostRules || postgate.quotepostRules.length === 0
} const noOneCanQuote =
postgate.quotepostRules?.length === 1 &&
const isEverybody = settings.length === 0 postgate.quotepostRules[0]?.$type === embeddingRules.disableRule.$type
const description = isEverybody const anyoneCanInteract = anyoneCanReply && anyoneCanQuote
? _(msg`Anyone can interact`) const noOneCanInteract = noOneCanReply && noOneCanQuote
const description = anyoneCanInteract
? _(msg`Anybody can interact`)
: noOneCanInteract
? _(msg`Nobody can interact`)
: _(msg`Interaction limited`) : _(msg`Interaction limited`)
const onPress = () => { const onPressOpen = () => {
if (isNative && Keyboard.isVisible()) { if (isNative && Keyboard.isVisible()) {
Keyboard.dismiss() Keyboard.dismiss()
} }
@@ -80,45 +95,94 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
} }
} }
const onEditConfirm = async (newSettings: ThreadgateAllowUISetting[]) => { const onChangePostgate = React.useCallback(
if (JSON.stringify(settings) === JSON.stringify(newSettings)) { (next: AppBskyFeedPostgate.Record) => {
return setPostgate(next)
} },
[setPostgate],
)
const onChangeThreadgateAllowUISettings = React.useCallback(
(next: ThreadgateAllowUISetting[]) => {
setSettings(next)
},
[setSettings],
)
const saveThreadgateAllowSettings = React.useCallback(async () => {
try { try {
await updateThreadgateAllow({ await updateThreadgateAllow({
agent, agent,
postUri: post.uri, postUri: post.uri,
allow: newSettings, allow: settings,
}) })
// TODO
await whenAppViewReady(agent, post.uri, res => { await whenAppViewReady(agent, post.uri, res => {
const thread = res.data.thread const thread = res.data.thread
if (AppBskyFeedDefs.isThreadViewPost(thread)) { if (AppBskyFeedDefs.isThreadViewPost(thread)) {
const fetchedSettings = threadgateViewToAllowUISetting( const fetchedSettings = threadgateViewToAllowUISetting(
thread.post.threadgate, thread.post.threadgate,
) )
return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) return JSON.stringify(fetchedSettings) === JSON.stringify(settings)
} }
return false return false
}) })
Toast.show(_(msg`Thread settings updated`))
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [POST_THREAD_RQKEY_ROOT], queryKey: [POST_THREAD_RQKEY_ROOT],
}) })
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [threadgateRecordQueryKeyRoot], queryKey: [threadgateRecordQueryKeyRoot],
}) })
} catch (err) { } catch (e: any) {
logger.error('Failed to edit threadgate', {safeMessage: e.message})
Toast.show( Toast.show(
_( _(
msg`There was an issue. Please check your internet connection and try again.`, msg`There was an issue. Please check your internet connection and try again.`,
), ),
'xmark', 'xmark',
) )
logger.error('Failed to edit threadgate', {message: err})
} }
} }, [_, agent, post, settings, queryClient])
const savePostgateRecord = React.useCallback(async () => {
try {
await writePostgateRecord({postUri: post.uri, postgate})
} catch (e: any) {
logger.error('Failed to save postgate', {safeMessage: e.message})
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
}
}, [_, post, postgate, writePostgateRecord])
const onSave = React.useCallback(async () => {
setIsSaving(true)
try {
await Promise.all([saveThreadgateAllowSettings(), savePostgateRecord()])
editDialogControl.close()
Toast.show(_(msg`Thread settings updated`))
} catch (e: any) {
Toast.show(
_(
msg`There was an issue. Please check your internet connection and try again.`,
),
'xmark',
)
} finally {
setIsSaving(false)
}
}, [
_,
editDialogControl,
saveThreadgateAllowSettings,
savePostgateRecord,
setIsSaving,
])
return ( return (
<> <>
@@ -126,7 +190,7 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
label={ label={
isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`)
} }
onPress={onPress} onPress={onPressOpen}
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]}>
@@ -154,9 +218,13 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) {
{isThreadAuthor ? ( {isThreadAuthor ? (
<ThreadgateEditorDialog <ThreadgateEditorDialog
onSave={onSave}
isSaving={isSaving}
postgate={postgate}
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={settings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
control={editDialogControl} control={editDialogControl}
threadgateUISettings={settings}
onConfirmThreadgateUISettings={onEditConfirm}
/> />
) : ( ) : (
<WhoCanReplyDialog <WhoCanReplyDialog
+166 -95
View File
@@ -1,79 +1,68 @@
import React from 'react' import React from 'react'
import {StyleProp, View, ViewStyle} from 'react-native' import {StyleProp, View, ViewStyle} from 'react-native'
import {AppBskyFeedPostgate} 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 isEqual from 'lodash.isequal' import isEqual from 'lodash.isequal'
import {useMyListsQuery} from '#/state/queries/my-lists' import {useMyListsQuery} from '#/state/queries/my-lists'
import {
createPostgateRecord,
embeddingRules,
} from '#/state/queries/postgate/util'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate' import {ThreadgateAllowUISetting} from '#/state/queries/threadgate'
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 * as Dialog from '#/components/Dialog' import * as Dialog from '#/components/Dialog'
import {Divider} from '#/components/Divider'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = { type Props = {
threadgateUISettings: ThreadgateAllowUISetting[] onSave: () => void
onChangeThreadgateUISettings?: (v: ThreadgateAllowUISetting[]) => void isSaving: boolean
onConfirmThreadgateUISettings?: (v: ThreadgateAllowUISetting[]) => void
postgate: AppBskyFeedPostgate.Record
onChangePostgate: (v: AppBskyFeedPostgate.Record) => void
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
} }
export function ThreadgateEditorDialog({ export function ThreadgateEditorDialog({
control, control,
onChangeThreadgateUISettings, ...rest
onConfirmThreadgateUISettings,
threadgateUISettings,
}: Props & { }: Props & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
}) { }) {
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<DialogContent <DialogContent {...rest} />
onChangeThreadgateUISettings={onChangeThreadgateUISettings}
onConfirmThreadgateUISettings={onConfirmThreadgateUISettings}
threadgateUISettings={threadgateUISettings}
/>
</Dialog.Outer> </Dialog.Outer>
) )
} }
function DialogContent({ function DialogContent({
onChangeThreadgateUISettings, onSave,
onConfirmThreadgateUISettings, isSaving,
threadgateUISettings, postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
}: Props) { }: Props) {
const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
const control = Dialog.useDialogContext() const control = Dialog.useDialogContext()
const {data: lists} = useMyListsQuery('curate') const {data: lists} = useMyListsQuery('curate')
const [draft, setDraft] = React.useState(threadgateUISettings)
const [prevThreadgateUISettings, setPrevThreadgateUISettings] =
React.useState(threadgateUISettings)
if (threadgateUISettings !== prevThreadgateUISettings) {
// New data flowed from above (e.g. due to update coming through).
setPrevThreadgateUISettings(threadgateUISettings)
setDraft(threadgateUISettings) // Reset draft.
}
function updateThreadgate(nextThreadgate: ThreadgateAllowUISetting[]) {
setDraft(nextThreadgate)
onChangeThreadgateUISettings?.(nextThreadgate)
}
const onPressEverybody = () => {
updateThreadgate([{type: 'everybody'}])
}
const onPressNobody = () => {
updateThreadgate([{type: 'nobody'}])
}
const onPressAudience = (setting: ThreadgateAllowUISetting) => { const onPressAudience = (setting: ThreadgateAllowUISetting) => {
// remove nobody // remove boolean values
let newSelected: ThreadgateAllowUISetting[] = draft.filter( let newSelected: ThreadgateAllowUISetting[] =
v => v.type !== 'nobody', threadgateAllowUISettings.filter(
) v => v.type !== 'nobody' && v.type !== 'everybody',
)
// toggle // toggle
const i = newSelected.findIndex(v => isEqual(v, setting)) const i = newSelected.findIndex(v => isEqual(v, setting))
if (i === -1) { if (i === -1) {
@@ -81,79 +70,161 @@ function DialogContent({
} else { } else {
newSelected.splice(i, 1) newSelected.splice(i, 1)
} }
updateThreadgate(newSelected)
onChangeThreadgateAllowUISettings(newSelected)
} }
const doneLabel = onConfirmThreadgateUISettings ? _(msg`Save`) : _(msg`Done`) const onChangeEmbeddingRules = React.useCallback(
(rules: AppBskyFeedPostgate.Record['quotepostRules']) => {
onChangePostgate(
createPostgateRecord({
...postgate,
quotepostRules: rules,
}),
)
},
[postgate, onChangePostgate],
)
const doneLabel = _(msg`Save`)
return ( return (
<Dialog.ScrollableInner <Dialog.ScrollableInner
label={_(msg`Choose who can reply`)} label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}> style={[{maxWidth: 500}, a.w_full]}>
<View style={[a.flex_1, a.gap_md]}> <View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}> <Text style={[a.text_2xl, a.font_bold]}>
<Trans>Choose who can reply</Trans> <Trans>Post interaction settings</Trans>
</Text> </Text>
<Text style={a.mt_xs}>
<Trans>Either choose "Everybody" or "Nobody"</Trans> <View style={[a.gap_lg]}>
</Text> <Text style={[a.text_md]}>
<View style={[a.flex_row, a.gap_sm]}> <Trans>Customize who can engage with this post.</Trans>
<Selectable </Text>
label={_(msg`Everybody`)}
isSelected={!!draft.find(v => v.type === 'everybody')} <Divider />
onPress={onPressEverybody}
style={{flex: 1}} <View style={[a.gap_sm]}>
/> <Text style={[a.font_bold, a.text_lg]}>
<Selectable <Trans>Quote settings</Trans>
label={_(msg`Nobody`)} </Text>
isSelected={!!draft.find(v => v.type === 'nobody')}
onPress={onPressNobody} <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}>
style={{flex: 1}} <Trans>Allow quote posts from:</Trans>
/> </Text>
</View>
<Text style={a.mt_md}> <View style={[a.flex_row, a.gap_sm]}>
<Trans>Or combine these options:</Trans> <Selectable
</Text> label={_(msg`Everybody`)}
<View style={[a.gap_sm]}> isSelected={
<Selectable !postgate.quotepostRules ||
label={_(msg`Mentioned users`)} postgate.quotepostRules?.length === 0
isSelected={!!draft.find(v => v.type === 'mention')} }
onPress={() => onPressAudience({type: 'mention'})} onPress={() => onChangeEmbeddingRules([])}
/> style={{flex: 1}}
<Selectable />
label={_(msg`Followed users`)} <Selectable
isSelected={!!draft.find(v => v.type === 'following')} label={_(msg`Nobody`)}
onPress={() => onPressAudience({type: 'following'})} isSelected={Boolean(
/> postgate.quotepostRules &&
{lists && lists.length > 0 postgate.quotepostRules.find(
? lists.map(list => ( v => v.$type === embeddingRules.disableRule.$type,
<Selectable ),
key={list.uri} )}
label={_(msg`Users in "${list.name}"`)} onPress={() =>
isSelected={ onChangeEmbeddingRules([embeddingRules.disableRule])
!!draft.find(v => v.type === 'list' && v.list === list.uri) }
} style={{flex: 1}}
onPress={() => />
onPressAudience({type: 'list', list: list.uri}) </View>
} </View>
/>
)) <Divider />
: // No loading states to avoid jumps for the common case (no lists)
null} <View style={[a.gap_sm]}>
<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}}
/>
<Selectable
label={_(msg`Nobody`)}
isSelected={
!!threadgateAllowUISettings.find(v => v.type === 'nobody')
}
onPress={() =>
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
/>
</View>
<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'})}
/>
<Selectable
label={_(msg`Followed users`)}
isSelected={
!!threadgateAllowUISettings.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={
!!threadgateAllowUISettings.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>
</View> </View>
</View> </View>
<Button <Button
label={doneLabel} label={doneLabel}
onPress={() => { onPress={onSave}
control.close()
onConfirmThreadgateUISettings?.(draft)
}}
onAccessibilityEscape={control.close} onAccessibilityEscape={control.close}
color="primary" color="primary"
size="medium" size="medium"
variant="solid" variant="solid"
style={a.mt_xl}> style={a.mt_xl}>
<ButtonText>{doneLabel}</ButtonText> <ButtonText>{doneLabel}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button> </Button>
<Dialog.Close /> <Dialog.Close />
</Dialog.ScrollableInner> </Dialog.ScrollableInner>
) )
+40 -14
View File
@@ -3,6 +3,7 @@ import {
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
AppBskyFeedPostgate,
BskyAgent, BskyAgent,
ComAtprotoLabelDefs, ComAtprotoLabelDefs,
ComAtprotoRepoUploadBlob, ComAtprotoRepoUploadBlob,
@@ -11,6 +12,7 @@ import {
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {logger} from '#/logger' import {logger} from '#/logger'
import {writePostgateRecord} from '#/state/queries/postgate'
import { import {
createThreadgateRecord, createThreadgateRecord,
ThreadgateAllowUISetting, ThreadgateAllowUISetting,
@@ -65,7 +67,8 @@ interface PostOpts {
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: ImageModel[] images?: ImageModel[]
labels?: string[] labels?: string[]
threadgate?: ThreadgateAllowUISetting[] threadgate: ThreadgateAllowUISetting[]
postgate: AppBskyFeedPostgate.Record
onStateChange?: (state: string) => void onStateChange?: (state: string) => void
langs?: string[] langs?: string[]
} }
@@ -253,7 +256,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.',
@@ -265,20 +270,41 @@ export async function post(agent: BskyAgent, opts: PostOpts) {
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 writeThreadgateRecord({ agent,
agent, postUri: res.uri,
postUri: res.uri, threadgate: createThreadgateRecord({
threadgate: createThreadgateRecord({ post: res.uri,
post: res.uri, allow: threadgateAllowUISettingToAllowRecordValue(opts.threadgate),
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.',
)
}
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.',
) )
} }
+25
View File
@@ -135,6 +135,31 @@ export function usePostgateQuery({postUri}: {postUri: string}) {
}) })
} }
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() { export function useToggleQuoteDetachmentMutation() {
const agent = useAgent() const agent = useAgent()
const queryClient = useQueryClient() const queryClient = useQueryClient()
+7 -5
View File
@@ -10,12 +10,10 @@ import {ViewRemoved} from '@atproto/api/dist/client/types/app/bsky/embed/record'
export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate' export const POSTGATE_COLLECTION = 'app.bsky.feed.postgate'
export function createPostgateRecord( export function createPostgateRecord(
postgate: Partial<AppBskyFeedPostgate.Record>, postgate: Partial<AppBskyFeedPostgate.Record> & {
post: AppBskyFeedPostgate.Record['post']
},
): AppBskyFeedPostgate.Record { ): AppBskyFeedPostgate.Record {
if (!postgate.post) {
throw new Error(`Cannot create a postgate record without a post URI`)
}
return { return {
$type: POSTGATE_COLLECTION, $type: POSTGATE_COLLECTION,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -190,3 +188,7 @@ export function getMaybeDetachedQuoteEmbed({
} }
} }
} }
export const embeddingRules = {
disableRule: {$type: 'app.bsky.feed.postgate#disableRule'},
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function threadgateViewToAllowUISetting(
* for 'replies disabled' other than an empty array. * for 'replies disabled' other than an empty array.
*/ */
if (!record || record.allow === undefined) { if (!record || record.allow === undefined) {
return [] return [{type: 'everybody'}]
} }
if (record.allow.length === 0) { if (record.allow.length === 0) {
return [{type: 'nobody'}] return [{type: 'nobody'}]
+15 -4
View File
@@ -58,9 +58,11 @@ import {
useLanguagePrefs, useLanguagePrefs,
useLanguagePrefsApi, useLanguagePrefsApi,
} from '#/state/preferences/languages' } from '#/state/preferences/languages'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {useProfileQuery} from '#/state/queries/profile' import {useProfileQuery} from '#/state/queries/profile'
import {Gif} from '#/state/queries/tenor' import {Gif} from '#/state/queries/tenor'
import {ThreadgateAllowUISetting} 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'
@@ -184,7 +186,11 @@ export const ComposePost = observer(function ComposePost({
const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState<Gif>() const [extGif, setExtGif] = useState<Gif>()
const [labels, setLabels] = useState<string[]>([]) const [labels, setLabels] = useState<string[]>([])
const [threadgate, setThreadgate] = useState<ThreadgateAllowUISetting[]>([]) 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),
@@ -334,7 +340,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),
}) })
@@ -664,8 +671,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,10 +1,12 @@
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 {embeddingRules} from '#/state/queries/postgate/util'
import {ThreadgateAllowUISetting} 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'
@@ -16,12 +18,18 @@ 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: ThreadgateAllowUISetting[] postgate: AppBskyFeedPostgate.Record
onChange: (v: ThreadgateAllowUISetting[]) => 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 +46,24 @@ 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 noOneCanReply =
: isNobody threadgateAllowUISettings.length === 1 &&
? _(msg`Nobody can reply`) threadgateAllowUISettings[0].type === 'nobody'
: _(msg`Some people can reply`) const anyoneCanQuote =
!postgate.quotepostRules || postgate.quotepostRules.length === 0
const noOneCanQuote =
postgate.quotepostRules?.length === 1 &&
postgate.quotepostRules[0]?.$type === embeddingRules.disableRule.$type
const anyoneCanInteract = anyoneCanReply && anyoneCanQuote
const noOneCanInteract = noOneCanReply && noOneCanQuote
const label = anyoneCanInteract
? _(msg`Anybody can interact`)
: noOneCanInteract
? _(msg`Nobody can interact`)
: _(msg`Interaction limited`)
return ( return (
<> <>
@@ -60,15 +79,27 @@ export function ThreadgateBtn({
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={isEverybody ? Earth : isNobody ? CircleBanSign : Group} icon={
anyoneCanInteract
? Earth
: noOneCanInteract
? CircleBanSign
: Group
}
/> />
<ButtonText>{label}</ButtonText> <ButtonText>{label}</ButtonText>
</Button> </Button>
</Animated.View> </Animated.View>
<ThreadgateEditorDialog <ThreadgateEditorDialog
control={control} control={control}
threadgateUISettings={threadgate} isSaving={false}
onChangeThreadgateUISettings={onChange} onSave={() => {
control.close()
}}
postgate={postgate}
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={onChangeThreadgateAllowUISettings}
/> />
</> </>
) )
+25 -2
View File
@@ -3,6 +3,7 @@ import {StyleSheet, View} from 'react-native'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedPostgate,
AtUri, AtUri,
ModerationDecision, ModerationDecision,
RichText as RichTextAPI, RichText as RichTextAPI,
@@ -15,6 +16,8 @@ import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow'
import {useLanguagePrefs} from '#/state/preferences' import {useLanguagePrefs} from '#/state/preferences'
import {useOpenLink} from '#/state/preferences/in-app-browser' import {useOpenLink} from '#/state/preferences/in-app-browser'
import {ThreadPost} from '#/state/queries/post-thread' import {ThreadPost} from '#/state/queries/post-thread'
import {usePostgateQuery} from '#/state/queries/postgate'
import {createPostgateRecord} from '#/state/queries/postgate/util'
import {useThreadgateRecordQuery} from '#/state/queries/threadgate' import {useThreadgateRecordQuery} from '#/state/queries/threadgate'
import {useComposerControls} from '#/state/shell/composer' import {useComposerControls} from '#/state/shell/composer'
import {MAX_POST_LINES} from 'lib/constants' import {MAX_POST_LINES} from 'lib/constants'
@@ -80,6 +83,7 @@ export function PostThreadItem({
onPostReply: (postUri: string | undefined) => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}) { }) {
const {data: postgate, isLoading} = usePostgateQuery({postUri: post.uri})
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
() => () =>
@@ -92,7 +96,7 @@ export function PostThreadItem({
if (postShadowed === POST_TOMBSTONE) { if (postShadowed === POST_TOMBSTONE) {
return <PostThreadItemDeleted hideTopBorder={hideTopBorder} /> return <PostThreadItemDeleted hideTopBorder={hideTopBorder} />
} }
if (richText && moderation) { if (richText && moderation && !isLoading) {
return ( return (
<PostThreadItemLoaded <PostThreadItemLoaded
// Safeguard from clobbering per-post state below: // Safeguard from clobbering per-post state below:
@@ -113,6 +117,7 @@ export function PostThreadItem({
overrideBlur={overrideBlur} overrideBlur={overrideBlur}
onPostReply={onPostReply} onPostReply={onPostReply}
hideTopBorder={hideTopBorder} hideTopBorder={hideTopBorder}
postgate={postgate}
/> />
) )
} }
@@ -156,6 +161,7 @@ let PostThreadItemLoaded = ({
overrideBlur, overrideBlur,
onPostReply, onPostReply,
hideTopBorder, hideTopBorder,
postgate,
}: { }: {
post: Shadow<AppBskyFeedDefs.PostView> post: Shadow<AppBskyFeedDefs.PostView>
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
@@ -173,6 +179,7 @@ let PostThreadItemLoaded = ({
overrideBlur: boolean overrideBlur: boolean
onPostReply: (postUri: string | undefined) => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
postgate: AppBskyFeedPostgate.Record | undefined
}): React.ReactNode => { }): React.ReactNode => {
const pal = usePalette('default') const pal = usePalette('default')
const {_} = useLingui() const {_} = useLingui()
@@ -360,6 +367,7 @@ let PostThreadItemLoaded = ({
isThreadAuthor={isThreadAuthor} isThreadAuthor={isThreadAuthor}
translatorUrl={translatorUrl} translatorUrl={translatorUrl}
needsTranslation={needsTranslation} needsTranslation={needsTranslation}
postgate={postgate}
/> />
{post.repostCount !== 0 || post.likeCount !== 0 ? ( {post.repostCount !== 0 || post.likeCount !== 0 ? (
// Show this section unless we're *sure* it has no engagement. // Show this section unless we're *sure* it has no engagement.
@@ -663,15 +671,18 @@ function ExpandedPostDetails({
isThreadAuthor, isThreadAuthor,
needsTranslation, needsTranslation,
translatorUrl, translatorUrl,
postgate,
}: { }: {
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
isThreadAuthor: boolean isThreadAuthor: boolean
needsTranslation: boolean needsTranslation: boolean
translatorUrl: string translatorUrl: string
postgate: AppBskyFeedPostgate.Record | undefined
}) { }) {
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)
@@ -688,7 +699,19 @@ function ExpandedPostDetails({
s.mb10, s.mb10,
]}> ]}>
<Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text>
<WhoCanReply post={post} isThreadAuthor={isThreadAuthor} /> {isRootPost && (
<WhoCanReply
post={post}
postgate={
// TODO maybe define at query
postgate ||
createPostgateRecord({
post: post.uri,
})
}
isThreadAuthor={isThreadAuthor}
/>
)}
{needsTranslation && ( {needsTranslation && (
<> <>
<Text style={[a.text_sm, pal.textLight]}>&middot;</Text> <Text style={[a.text_sm, pal.textLight]}>&middot;</Text>