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