diff options
Diffstat (limited to 'src/components/dialogs/PostInteractionSettingsDialog.tsx')
-rw-r--r-- | src/components/dialogs/PostInteractionSettingsDialog.tsx | 538 |
1 files changed, 538 insertions, 0 deletions
diff --git a/src/components/dialogs/PostInteractionSettingsDialog.tsx b/src/components/dialogs/PostInteractionSettingsDialog.tsx new file mode 100644 index 000000000..a326602b7 --- /dev/null +++ b/src/components/dialogs/PostInteractionSettingsDialog.tsx @@ -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]) +} |