Add post interaction settings to dropdown for all users

This commit is contained in:
Eric Bailey
2024-08-09 16:23:08 -05:00
parent c70f33ebe2
commit 045c511196
4 changed files with 208 additions and 33 deletions
@@ -16,10 +16,11 @@ 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 {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 {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
type Props = { export type Props = {
onSave: () => void onSave: () => void
isSaving: boolean isSaving: boolean
@@ -28,6 +29,8 @@ type Props = {
threadgateAllowUISettings: ThreadgateAllowUISetting[] threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
} }
export function PostInteractionSettingsDialog({ export function PostInteractionSettingsDialog({
@@ -36,21 +39,28 @@ export function PostInteractionSettingsDialog({
}: Props & { }: Props & {
control: Dialog.DialogControlProps control: Dialog.DialogControlProps
}) { }) {
const {_} = useLingui()
return ( return (
<Dialog.Outer control={control}> <Dialog.Outer control={control}>
<Dialog.Handle /> <Dialog.Handle />
<DialogContent {...rest} /> <Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<PostInteractionSettingsDialogInner {...rest} />
<Dialog.Close />
</Dialog.ScrollableInner>
</Dialog.Outer> </Dialog.Outer>
) )
} }
function DialogContent({ export function PostInteractionSettingsDialogInner({
onSave, onSave,
isSaving, isSaving,
postgate, postgate,
onChangePostgate, onChangePostgate,
threadgateAllowUISettings, threadgateAllowUISettings,
onChangeThreadgateAllowUISettings, onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: Props) { }: Props) {
const t = useTheme() const t = useTheme()
const {_} = useLingui() const {_} = useLingui()
@@ -91,9 +101,7 @@ function DialogContent({
) )
return ( return (
<Dialog.ScrollableInner <View>
label={_(msg`Edit post interaction settings`)}
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>Post interaction settings</Trans> <Trans>Post interaction settings</Trans>
@@ -143,7 +151,31 @@ function DialogContent({
<Divider /> <Divider />
<View style={[a.gap_sm]}> {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.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>Reply settings are inherited from the root post</Trans>
</Text>
</View>
)}
<View
style={[
a.gap_sm,
{
opacity: replySettingsDisabled ? 0.3 : 1,
},
]}>
<Text style={[a.font_bold, a.text_lg]}> <Text style={[a.font_bold, a.text_lg]}>
<Trans>Reply settings</Trans> <Trans>Reply settings</Trans>
</Text> </Text>
@@ -162,6 +194,7 @@ function DialogContent({
onChangeThreadgateAllowUISettings([{type: 'everybody'}]) onChangeThreadgateAllowUISettings([{type: 'everybody'}])
} }
style={{flex: 1}} style={{flex: 1}}
disabled={replySettingsDisabled}
/> />
<Selectable <Selectable
label={_(msg`Nobody`)} label={_(msg`Nobody`)}
@@ -170,6 +203,7 @@ function DialogContent({
onChangeThreadgateAllowUISettings([{type: 'nobody'}]) onChangeThreadgateAllowUISettings([{type: 'nobody'}])
} }
style={{flex: 1}} style={{flex: 1}}
disabled={replySettingsDisabled}
/> />
</View> </View>
@@ -188,6 +222,7 @@ function DialogContent({
) )
} }
onPress={() => onPressAudience({type: 'mention'})} onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/> />
<Selectable <Selectable
label={_(msg`Followed users`)} label={_(msg`Followed users`)}
@@ -197,6 +232,7 @@ function DialogContent({
) )
} }
onPress={() => onPressAudience({type: 'following'})} onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/> />
{lists && lists.length > 0 {lists && lists.length > 0
? lists.map(list => ( ? lists.map(list => (
@@ -211,6 +247,7 @@ function DialogContent({
onPress={() => onPress={() =>
onPressAudience({type: 'list', list: list.uri}) onPressAudience({type: 'list', list: list.uri})
} }
disabled={replySettingsDisabled}
/> />
)) ))
: // No loading states to avoid jumps for the common case (no lists) : // No loading states to avoid jumps for the common case (no lists)
@@ -233,9 +270,7 @@ function DialogContent({
<ButtonText>{_(msg`Save`)}</ButtonText> <ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />} {isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button> </Button>
</View>
<Dialog.Close />
</Dialog.ScrollableInner>
) )
} }
@@ -244,15 +279,18 @@ function Selectable({
isSelected, isSelected,
onPress, onPress,
style, style,
disabled,
}: { }: {
label: string label: string
isSelected: boolean isSelected: boolean
onPress: () => void onPress: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
disabled?: boolean
}) { }) {
const t = useTheme() const t = useTheme()
return ( return (
<Button <Button
disabled={disabled}
onPress={onPress} onPress={onPress}
label={label} label={label}
accessibilityHint="Select this option" accessibilityHint="Select this option"
+19 -13
View File
@@ -2,35 +2,41 @@ import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
import {ThreadgateAllowUISetting} from '#/state/queries/threadgate/types' 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 * Converts a full {@link AppBskyFeedThreadgate.Record} to a list of
* {@link ThreadgateAllowUISetting}, for use by app UI. * {@link ThreadgateAllowUISetting}, for use by app UI.
*/ */
export function threadgateViewToAllowUISetting( export function threadgateRecordToAllowUISetting(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined, threadgate: AppBskyFeedThreadgate.Record | undefined,
): ThreadgateAllowUISetting[] { ): ThreadgateAllowUISetting[] {
const record =
threadgate &&
AppBskyFeedThreadgate.isRecord(threadgate.record) &&
AppBskyFeedThreadgate.validateRecord(threadgate.record).success
? threadgate.record
: null
/* /*
* If record doesn't exist (default), or if `record.allow === undefined`, it means * If `threadgate` doesn't exist (default), or if `threadgate.allow === undefined`, it means
* anyone can reply. * anyone can reply.
* *
* If `record.allow === []` it means no one can reply, and we translate to UI code * 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 * here. This was a historical choice, and we have no lexicon representation
* for 'replies disabled' other than an empty array. * for 'replies disabled' other than an empty array.
*/ */
if (!record || record.allow === undefined) { if (!threadgate || threadgate.allow === undefined) {
return [{type: 'everybody'}] return [{type: 'everybody'}]
} }
if (record.allow.length === 0) { if (threadgate.allow.length === 0) {
return [{type: 'nobody'}] return [{type: 'nobody'}]
} }
const settings: ThreadgateAllowUISetting[] = record.allow const settings: ThreadgateAllowUISetting[] = threadgate.allow
.map(allow => { .map(allow => {
let setting: ThreadgateAllowUISetting | undefined let setting: ThreadgateAllowUISetting | undefined
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') { if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
@@ -83,6 +83,7 @@ export function PostThreadItem({
onPostReply: (postUri: string | undefined) => void onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean hideTopBorder?: boolean
}) { }) {
// TODO should load this for every post, ugh
const {data: postgate, isLoading} = usePostgateQuery({postUri: post.uri}) const {data: postgate, isLoading} = usePostgateQuery({postUri: post.uri})
const postShadowed = usePostShadow(post) const postShadowed = usePostShadow(post)
const richText = useMemo( const richText = useMemo(
+140 -10
View File
@@ -9,6 +9,7 @@ import * as Clipboard from 'expo-clipboard'
import { import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedPost, AppBskyFeedPost,
AppBskyFeedPostgate,
AppBskyFeedThreadgate, AppBskyFeedThreadgate,
AtUri, AtUri,
RichText as RichTextAPI, RichText as RichTextAPI,
@@ -32,9 +33,17 @@ import {
usePostDeleteMutation, usePostDeleteMutation,
useThreadMuteMutationQueue, useThreadMuteMutationQueue,
} from '#/state/queries/post' } from '#/state/queries/post'
import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate' import {
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util' usePostgateQuery,
useToggleQuoteDetachmentMutation,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
getMaybeDetachedQuoteEmbed,
} from '#/state/queries/postgate/util'
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate' import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate/util'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {getCurrentRoute} from 'lib/routes/helpers' import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing' import {shareUrl} from 'lib/sharing'
@@ -42,8 +51,10 @@ import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext' import {useTheme} from 'lib/ThemeContext'
import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf' import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
import * as Dialog 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 {PostInteractionSettingsDialogInner} 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'
@@ -59,6 +70,7 @@ import {EyeSlash_Stroke2_Corner0_Rounded as EyeSlash} from '#/components/icons/E
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'
@@ -114,6 +126,7 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl() const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl() const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl() const sendViaChatControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} = const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation() useToggleReplyVisibilityMutation()
@@ -136,6 +149,8 @@ let PostDropdownBtn = ({
) )
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 =
rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
const isReplyHiddenByThreadgate = const isReplyHiddenByThreadgate =
threadgateRecord?.hiddenReplies?.includes(postUri) threadgateRecord?.hiddenReplies?.includes(postUri)
@@ -322,7 +337,8 @@ let PostDropdownBtn = ({
}, [_, quoteEmbed, post, toggleQuoteDetachment]) }, [_, quoteEmbed, post, toggleQuoteDetachment])
const canEmbed = isWeb && gtMobile && !hideInPWI const canEmbed = isWeb && gtMobile && !hideInPWI
const canHideReply = !isAuthor && !isPostHidden && rootPostUri && isReply const canHideReply =
!isAuthor && isRootPostAuthor && !isPostHidden && rootPostUri && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
return ( return (
@@ -549,13 +565,24 @@ let PostDropdownBtn = ({
)} )}
{isAuthor && ( {isAuthor && (
<Menu.Item <>
testID="postDropdownDeleteBtn" <Menu.Item
label={_(msg`Delete post`)} testID="postDropdownEditPostInteractions"
onPress={deletePromptControl.open}> label={_(msg`Edit interaction settings`)}
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText> onPress={postInteractionSettingsDialogControl.open}>
<Menu.ItemIcon icon={Trash} position="right" /> <Menu.ItemText>
</Menu.Item> {_(msg`Edit interaction settings`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
)} )}
</Menu.Group> </Menu.Group>
</> </>
@@ -616,9 +643,112 @@ let PostDropdownBtn = ({
control={sendViaChatControl} control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo} onSelectChat={onSelectChatToShareTo}
/> />
<PostInteractionSettingsDialogControlled
control={postInteractionSettingsDialogControl}
postUri={post.uri}
threadgateRecord={threadgateRecord}
/>
</EventStopper> </EventStopper>
) )
} }
export function PostInteractionSettingsDialogControlled(props: {
control: Dialog.DialogControlProps
postUri: string
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
return (
<Dialog.Outer control={props.control}>
<Dialog.Handle />
<PostInteractionSettingsDialogControlledInner {...props} />
</Dialog.Outer>
)
}
function PostInteractionSettingsDialogControlledInner(props: {
control: Dialog.DialogControlProps
postUri: string
threadgateRecord?: AppBskyFeedThreadgate.Record
}) {
const {_} = useLingui()
const threadgateAllowUISettings = React.useMemo(() => {
return threadgateRecordToAllowUISetting(props.threadgateRecord)
}, [props.threadgateRecord])
const [isSaving, setIsSaving] = React.useState(false)
const {mutateAsync: writePostgateRecord} = useWritePostgateMutation()
const {data: postgate, isLoading} = usePostgateQuery({postUri: props.postUri})
const [editedPostgate, setEditedPostgate] =
React.useState<AppBskyFeedPostgate.Record>()
const onChangePostgate = React.useCallback(
(next: AppBskyFeedPostgate.Record) => {
setEditedPostgate(next)
},
[setEditedPostgate],
)
const onSave = React.useCallback(async () => {
if (!editedPostgate) {
props.control.close()
return
}
setIsSaving(true)
try {
await writePostgateRecord({
postUri: props.postUri,
postgate: editedPostgate,
})
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.control,
editedPostgate,
setIsSaving,
writePostgateRecord,
])
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
{isLoading ? (
<Loader size="xl" />
) : (
<PostInteractionSettingsDialogInner
replySettingsDisabled
isSaving={isSaving}
onSave={onSave}
postgate={
editedPostgate ||
postgate ||
createPostgateRecord({post: props.postUri})
}
onChangePostgate={onChangePostgate}
threadgateAllowUISettings={threadgateAllowUISettings}
onChangeThreadgateAllowUISettings={() => {}}
/>
)}
</Dialog.ScrollableInner>
)
}
PostDropdownBtn = memo(PostDropdownBtn) PostDropdownBtn = memo(PostDropdownBtn)
export {PostDropdownBtn} export {PostDropdownBtn}