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 {Divider} from '#/components/Divider'
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'
type Props = {
export type Props = {
onSave: () => void
isSaving: boolean
@@ -28,6 +29,8 @@ type Props = {
threadgateAllowUISettings: ThreadgateAllowUISetting[]
onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void
replySettingsDisabled?: boolean
}
export function PostInteractionSettingsDialog({
@@ -36,21 +39,28 @@ export function PostInteractionSettingsDialog({
}: Props & {
control: Dialog.DialogControlProps
}) {
const {_} = useLingui()
return (
<Dialog.Outer control={control}>
<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>
)
}
function DialogContent({
export function PostInteractionSettingsDialogInner({
onSave,
isSaving,
postgate,
onChangePostgate,
threadgateAllowUISettings,
onChangeThreadgateAllowUISettings,
replySettingsDisabled,
}: Props) {
const t = useTheme()
const {_} = useLingui()
@@ -91,9 +101,7 @@ function DialogContent({
)
return (
<Dialog.ScrollableInner
label={_(msg`Edit post interaction settings`)}
style={[{maxWidth: 500}, a.w_full]}>
<View>
<View style={[a.flex_1, a.gap_md]}>
<Text style={[a.text_2xl, a.font_bold]}>
<Trans>Post interaction settings</Trans>
@@ -143,7 +151,31 @@ function DialogContent({
<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]}>
<Trans>Reply settings</Trans>
</Text>
@@ -162,6 +194,7 @@ function DialogContent({
onChangeThreadgateAllowUISettings([{type: 'everybody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Nobody`)}
@@ -170,6 +203,7 @@ function DialogContent({
onChangeThreadgateAllowUISettings([{type: 'nobody'}])
}
style={{flex: 1}}
disabled={replySettingsDisabled}
/>
</View>
@@ -188,6 +222,7 @@ function DialogContent({
)
}
onPress={() => onPressAudience({type: 'mention'})}
disabled={replySettingsDisabled}
/>
<Selectable
label={_(msg`Followed users`)}
@@ -197,6 +232,7 @@ function DialogContent({
)
}
onPress={() => onPressAudience({type: 'following'})}
disabled={replySettingsDisabled}
/>
{lists && lists.length > 0
? lists.map(list => (
@@ -211,6 +247,7 @@ function DialogContent({
onPress={() =>
onPressAudience({type: 'list', list: list.uri})
}
disabled={replySettingsDisabled}
/>
))
: // No loading states to avoid jumps for the common case (no lists)
@@ -233,9 +270,7 @@ function DialogContent({
<ButtonText>{_(msg`Save`)}</ButtonText>
{isSaving && <ButtonIcon icon={Loader} position="right" />}
</Button>
<Dialog.Close />
</Dialog.ScrollableInner>
</View>
)
}
@@ -244,15 +279,18 @@ function Selectable({
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}
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'
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 threadgateViewToAllowUISetting(
threadgate: AppBskyFeedDefs.ThreadgateView | undefined,
export function threadgateRecordToAllowUISetting(
threadgate: AppBskyFeedThreadgate.Record | undefined,
): 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.
*
* 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
* for 'replies disabled' other than an empty array.
*/
if (!record || record.allow === undefined) {
if (!threadgate || threadgate.allow === undefined) {
return [{type: 'everybody'}]
}
if (record.allow.length === 0) {
if (threadgate.allow.length === 0) {
return [{type: 'nobody'}]
}
const settings: ThreadgateAllowUISetting[] = record.allow
const settings: ThreadgateAllowUISetting[] = threadgate.allow
.map(allow => {
let setting: ThreadgateAllowUISetting | undefined
if (allow.$type === 'app.bsky.feed.threadgate#mentionRule') {
@@ -83,6 +83,7 @@ export function PostThreadItem({
onPostReply: (postUri: string | undefined) => void
hideTopBorder?: boolean
}) {
// TODO should load this for every post, ugh
const {data: postgate, isLoading} = usePostgateQuery({postUri: post.uri})
const postShadowed = usePostShadow(post)
const richText = useMemo(
+140 -10
View File
@@ -9,6 +9,7 @@ import * as Clipboard from 'expo-clipboard'
import {
AppBskyFeedDefs,
AppBskyFeedPost,
AppBskyFeedPostgate,
AppBskyFeedThreadgate,
AtUri,
RichText as RichTextAPI,
@@ -32,9 +33,17 @@ import {
usePostDeleteMutation,
useThreadMuteMutationQueue,
} from '#/state/queries/post'
import {useToggleQuoteDetachmentMutation} from '#/state/queries/postgate'
import {getMaybeDetachedQuoteEmbed} from '#/state/queries/postgate/util'
import {
usePostgateQuery,
useToggleQuoteDetachmentMutation,
useWritePostgateMutation,
} from '#/state/queries/postgate'
import {
createPostgateRecord,
getMaybeDetachedQuoteEmbed,
} from '#/state/queries/postgate/util'
import {useToggleReplyVisibilityMutation} from '#/state/queries/threadgate'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate/util'
import {useSession} from '#/state/session'
import {getCurrentRoute} from 'lib/routes/helpers'
import {shareUrl} from 'lib/sharing'
@@ -42,8 +51,10 @@ import {toShareUrl} from 'lib/strings/url-helpers'
import {useTheme} from 'lib/ThemeContext'
import {atoms as a, useBreakpoints, useTheme as useAlf} from '#/alf'
import {useDialogControl} from '#/components/Dialog'
import * as Dialog from '#/components/Dialog'
import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
import {EmbedDialog} from '#/components/dialogs/Embed'
import {PostInteractionSettingsDialogInner} from '#/components/dialogs/PostInteractionSettingsDialog'
import {SendViaChatDialog} from '#/components/dms/dialogs/ShareViaChatDialog'
import {ArrowOutOfBox_Stroke2_Corner0_Rounded as Share} from '#/components/icons/ArrowOutOfBox'
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 {Mute_Stroke2_Corner0_Rounded as Mute} from '#/components/icons/Mute'
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 {Trash_Stroke2_Corner0_Rounded as Trash} from '#/components/icons/Trash'
import {Warning_Stroke2_Corner0_Rounded as Warning} from '#/components/icons/Warning'
@@ -114,6 +126,7 @@ let PostDropdownBtn = ({
const loggedOutWarningPromptControl = useDialogControl()
const embedPostControl = useDialogControl()
const sendViaChatControl = useDialogControl()
const postInteractionSettingsDialogControl = useDialogControl()
const {mutateAsync: toggleReplyVisibility} =
useToggleReplyVisibilityMutation()
@@ -136,6 +149,8 @@ let PostDropdownBtn = ({
)
const isPostHidden = hiddenPosts && hiddenPosts.includes(postUri)
const isAuthor = postAuthor.did === currentAccount?.did
const isRootPostAuthor =
rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
const isReplyHiddenByThreadgate =
threadgateRecord?.hiddenReplies?.includes(postUri)
@@ -322,7 +337,8 @@ let PostDropdownBtn = ({
}, [_, quoteEmbed, post, toggleQuoteDetachment])
const canEmbed = isWeb && gtMobile && !hideInPWI
const canHideReply = !isAuthor && !isPostHidden && rootPostUri && isReply
const canHideReply =
!isAuthor && isRootPostAuthor && !isPostHidden && rootPostUri && isReply
const canDetachQuote = quoteEmbed && quoteEmbed.isOwnedByViewer
return (
@@ -549,13 +565,24 @@ let PostDropdownBtn = ({
)}
{isAuthor && (
<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.Item
testID="postDropdownEditPostInteractions"
label={_(msg`Edit interaction settings`)}
onPress={postInteractionSettingsDialogControl.open}>
<Menu.ItemText>
{_(msg`Edit interaction settings`)}
</Menu.ItemText>
<Menu.ItemIcon icon={Gear} position="right" />
</Menu.Item>
<Menu.Item
testID="postDropdownDeleteBtn"
label={_(msg`Delete post`)}
onPress={deletePromptControl.open}>
<Menu.ItemText>{_(msg`Delete post`)}</Menu.ItemText>
<Menu.ItemIcon icon={Trash} position="right" />
</Menu.Item>
</>
)}
</Menu.Group>
</>
@@ -616,9 +643,112 @@ let PostDropdownBtn = ({
control={sendViaChatControl}
onSelectChat={onSelectChatToShareTo}
/>
<PostInteractionSettingsDialogControlled
control={postInteractionSettingsDialogControl}
postUri={post.uri}
threadgateRecord={threadgateRecord}
/>
</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)
export {PostDropdownBtn}