diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/Pills.tsx | 11 | ||||
-rw-r--r-- | src/components/WhoCanReply.tsx | 293 | ||||
-rw-r--r-- | src/components/dialogs/PostInteractionSettingsDialog.tsx | 538 | ||||
-rw-r--r-- | src/components/dialogs/ThreadgateEditor.tsx | 217 | ||||
-rw-r--r-- | src/components/icons/Eye.tsx | 5 | ||||
-rw-r--r-- | src/components/moderation/ModerationDetailsDialog.tsx | 19 | ||||
-rw-r--r-- | src/components/moderation/PostAlerts.tsx | 14 |
7 files changed, 707 insertions, 390 deletions
diff --git a/src/components/Pills.tsx b/src/components/Pills.tsx index 2fff99937..742a11667 100644 --- a/src/components/Pills.tsx +++ b/src/components/Pills.tsx @@ -13,6 +13,15 @@ import { } from '#/components/moderation/ModerationDetailsDialog' import {Text} from '#/components/Typography' +export type AppModerationCause = + | ModerationCause + | { + type: 'reply-hidden' + source: {type: 'user'; did: string} + priority: 6 + downgraded?: boolean + } + export type CommonProps = { size?: 'sm' | 'lg' } @@ -40,7 +49,7 @@ export function Row({ } export type LabelProps = { - cause: ModerationCause + cause: AppModerationCause disableDetailsDialog?: boolean noBg?: boolean } & CommonProps diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx index 1ffb4da39..ab6ef8293 100644 --- a/src/components/WhoCanReply.tsx +++ b/src/components/WhoCanReply.tsx @@ -1,39 +1,34 @@ import React from 'react' -import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import {Keyboard, Platform, StyleProp, View, ViewStyle} from 'react-native' import { AppBskyFeedDefs, - AppBskyFeedGetPostThread, + AppBskyFeedPost, AppBskyGraphDefs, AtUri, - BskyAgent, } from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {useQueryClient} from '@tanstack/react-query' -import {createThreadgate} from '#/lib/api' -import {until} from '#/lib/async/until' import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' -import {logger} from '#/logger' import {isNative} from '#/platform/detection' -import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' import { - ThreadgateSetting, - threadgateViewToSettings, + ThreadgateAllowUISetting, + threadgateViewToAllowUISetting, } from '#/state/queries/threadgate' -import {useAgent} from '#/state/session' -import * as Toast from 'view/com/util/Toast' import {atoms as a, useTheme} from '#/alf' import {Button} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog' +import { + PostInteractionSettingsDialog, + usePrefetchPostInteractionSettings, +} from '#/components/dialogs/PostInteractionSettingsDialog' import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' +import {InlineLinkText} from '#/components/Link' import {Text} from '#/components/Typography' -import {TextLink} from '../view/com/util/Link' -import {ThreadgateEditorDialog} from './dialogs/ThreadgateEditor' import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' interface WhoCanReplyProps { @@ -47,31 +42,34 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { const t = useTheme() const infoDialogControl = useDialogControl() const editDialogControl = useDialogControl() - const agent = useAgent() - const queryClient = useQueryClient() - const settings = React.useMemo( - () => threadgateViewToSettings(post.threadgate), - [post], - ) - const isRootPost = !('reply' in post.record) + /* + * `WhoCanReply` is only used for root posts atm, in case this changes + * unexpectedly, we should check to make sure it's for sure the root URI. + */ + const rootUri = + AppBskyFeedPost.isRecord(post.record) && post.record.reply?.root + ? post.record.reply.root.uri + : post.uri + const settings = React.useMemo(() => { + return threadgateViewToAllowUISetting(post.threadgate) + }, [post.threadgate]) - if (!isRootPost) { - return null - } - if (!settings.length && !isThreadAuthor) { - return null - } + const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ + postUri: post.uri, + rootPostUri: rootUri, + }) - const isEverybody = settings.length === 0 - const isNobody = !!settings.find(gate => gate.type === 'nobody') - const description = isEverybody + const anyoneCanReply = + settings.length === 1 && settings[0].type === 'everybody' + const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody' + const description = anyoneCanReply ? _(msg`Everybody can reply`) - : isNobody + : noOneCanReply ? _(msg`Replies disabled`) : _(msg`Some people can reply`) - const onPressEdit = () => { + const onPressOpen = () => { if (isNative && Keyboard.isVisible()) { Keyboard.dismiss() } @@ -82,52 +80,23 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { } } - const onEditConfirm = async (newSettings: ThreadgateSetting[]) => { - if (JSON.stringify(settings) === JSON.stringify(newSettings)) { - return - } - try { - if (newSettings.length) { - await createThreadgate(agent, post.uri, newSettings) - } else { - await agent.api.com.atproto.repo.deleteRecord({ - repo: agent.session!.did, - collection: 'app.bsky.feed.threadgate', - rkey: new AtUri(post.uri).rkey, - }) - } - await whenAppViewReady(agent, post.uri, res => { - const thread = res.data.thread - if (AppBskyFeedDefs.isThreadViewPost(thread)) { - const fetchedSettings = threadgateViewToSettings( - thread.post.threadgate, - ) - return JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) - } - return false - }) - Toast.show(_(msg`Thread settings updated`)) - queryClient.invalidateQueries({ - queryKey: [POST_THREAD_RQKEY_ROOT], - }) - } catch (err) { - Toast.show( - _( - msg`There was an issue. Please check your internet connection and try again.`, - ), - 'xmark', - ) - logger.error('Failed to edit threadgate', {message: err}) - } - } - return ( <> <Button label={ isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) } - onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} + onPress={onPressOpen} + {...(isThreadAuthor + ? Platform.select({ + web: { + onHoverIn: prefetchPostInteractionSettings, + }, + native: { + onPressIn: prefetchPostInteractionSettings, + }, + }) + : {})} hitSlop={HITSLOP_10}> {({hovered}) => ( <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> @@ -145,22 +114,27 @@ export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { ]}> {description} </Text> + {isThreadAuthor && ( <PencilLine width={12} fill={t.palette.primary_500} /> )} </View> )} </Button> - <WhoCanReplyDialog - control={infoDialogControl} - post={post} - settings={settings} - /> - {isThreadAuthor && ( - <ThreadgateEditorDialog + + {isThreadAuthor ? ( + <PostInteractionSettingsDialog + postUri={post.uri} + rootPostUri={rootUri} control={editDialogControl} - threadgate={settings} - onConfirm={onEditConfirm} + initialThreadgateView={post.threadgate} + /> + ) : ( + <WhoCanReplyDialog + control={infoDialogControl} + post={post} + settings={settings} + embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} /> )} </> @@ -174,7 +148,7 @@ function Icon({ }: { color: string width?: number - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] }) { const isEverybody = settings.length === 0 const isNobody = !!settings.find(gate => gate.type === 'nobody') @@ -186,79 +160,84 @@ function WhoCanReplyDialog({ control, post, settings, + embeddingDisabled, }: { control: Dialog.DialogControlProps post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { + const {_} = useLingui() return ( <Dialog.Outer control={control}> <Dialog.Handle /> - <WhoCanReplyDialogInner post={post} settings={settings} /> + <Dialog.ScrollableInner + label={_(msg`Dialog: adjust who can interact with this post`)} + style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> + <View style={[a.gap_sm]}> + <Text style={[a.font_bold, a.text_xl, a.pb_sm]}> + <Trans>Who can interact with this post?</Trans> + </Text> + <Rules + post={post} + settings={settings} + embeddingDisabled={embeddingDisabled} + /> + </View> + </Dialog.ScrollableInner> </Dialog.Outer> ) } -function WhoCanReplyDialogInner({ - post, - settings, -}: { - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] -}) { - const {_} = useLingui() - return ( - <Dialog.ScrollableInner - label={_(msg`Who can reply dialog`)} - style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> - <View style={[a.gap_sm]}> - <Text style={[a.font_bold, a.text_xl]}> - <Trans>Who can reply?</Trans> - </Text> - <Rules post={post} settings={settings} /> - </View> - </Dialog.ScrollableInner> - ) -} - function Rules({ post, settings, + embeddingDisabled, }: { post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] + settings: ThreadgateAllowUISetting[] + embeddingDisabled: boolean }) { const t = useTheme() + return ( - <Text - style={[ - a.text_md, - a.leading_tight, - a.flex_wrap, - t.atoms.text_contrast_medium, - ]}> - {!settings.length ? ( - <Trans>Everybody can reply</Trans> - ) : settings[0].type === 'nobody' ? ( - <Trans>Replies to this thread are disabled</Trans> - ) : ( - <Trans> - Only{' '} - {settings.map((rule, i) => ( - <> - <Rule - key={`rule-${i}`} - rule={rule} - post={post} - lists={post.threadgate!.lists} - /> - <Separator key={`sep-${i}`} i={i} length={settings.length} /> - </> - ))}{' '} - can reply - </Trans> + <> + <Text + style={[ + a.text_sm, + a.leading_snug, + a.flex_wrap, + t.atoms.text_contrast_medium, + ]}> + {settings[0].type === 'everybody' ? ( + <Trans>Everybody can reply to this post.</Trans> + ) : settings[0].type === 'nobody' ? ( + <Trans>Replies to this post are disabled.</Trans> + ) : ( + <Trans> + Only{' '} + {settings.map((rule, i) => ( + <React.Fragment key={`rule-${i}`}> + <Rule rule={rule} post={post} lists={post.threadgate!.lists} /> + <Separator i={i} length={settings.length} /> + </React.Fragment> + ))}{' '} + can reply. + </Trans> + )}{' '} + </Text> + {embeddingDisabled && ( + <Text + style={[ + a.text_sm, + a.leading_snug, + a.flex_wrap, + t.atoms.text_contrast_medium, + ]}> + <Trans>No one but the author can quote this post.</Trans> + </Text> )} - </Text> + </> ) } @@ -267,11 +246,10 @@ function Rule({ post, lists, }: { - rule: ThreadgateSetting + rule: ThreadgateAllowUISetting post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { - const t = useTheme() if (rule.type === 'mention') { return <Trans>mentioned users</Trans> } @@ -279,12 +257,12 @@ function Rule({ return ( <Trans> users followed by{' '} - <TextLink - type="sm" - href={makeProfileLink(post.author)} - text={`@${post.author.handle}`} - style={{color: t.palette.primary_500}} - /> + <InlineLinkText + label={`@${post.author.handle}`} + to={makeProfileLink(post.author)} + style={[a.text_sm, a.leading_snug]}> + @{post.author.handle} + </InlineLinkText> </Trans> ) } @@ -294,12 +272,12 @@ function Rule({ const listUrip = new AtUri(list.uri) return ( <Trans> - <TextLink - type="sm" - href={makeListLink(listUrip.hostname, listUrip.rkey)} - text={list.name} - style={{color: t.palette.primary_500}} - />{' '} + <InlineLinkText + label={list.name} + to={makeListLink(listUrip.hostname, listUrip.rkey)} + style={[a.text_sm, a.leading_snug]}> + {list.name} + </InlineLinkText>{' '} members </Trans> ) @@ -320,20 +298,3 @@ function Separator({i, length}: {i: number; length: number}) { } return <>, </> } - -async function whenAppViewReady( - agent: BskyAgent, - uri: string, - fn: (res: AppBskyFeedGetPostThread.Response) => boolean, -) { - await until( - 5, // 5 tries - 1e3, // 1s delay between tries - fn, - () => - agent.app.bsky.feed.getPostThread({ - uri, - depth: 0, - }), - ) -} 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> - ) -} diff --git a/src/components/icons/Eye.tsx b/src/components/icons/Eye.tsx new file mode 100644 index 000000000..afa772e1d --- /dev/null +++ b/src/components/icons/Eye.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const Eye_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3.135 12C5.413 16.088 8.77 18 12 18s6.587-1.912 8.865-6C18.587 7.912 15.23 6 12 6c-3.228 0-6.587 1.912-8.865 6ZM12 4c4.24 0 8.339 2.611 10.888 7.54a1 1 0 0 1 0 .92C20.338 17.388 16.24 20 12 20c-4.24 0-8.339-2.611-10.888-7.54a1 1 0 0 1 0-.92C3.662 6.612 7.76 4 12 4Zm0 6a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm-4 2a4 4 0 1 1 8 0 4 4 0 0 1-8 0Z', +}) diff --git a/src/components/moderation/ModerationDetailsDialog.tsx b/src/components/moderation/ModerationDetailsDialog.tsx index b8f02582c..d95717cf4 100644 --- a/src/components/moderation/ModerationDetailsDialog.tsx +++ b/src/components/moderation/ModerationDetailsDialog.tsx @@ -8,17 +8,19 @@ import {useModerationCauseDescription} from '#/lib/moderation/useModerationCause import {makeProfileLink} from '#/lib/routes/links' import {listUriToHref} from '#/lib/strings/url-helpers' import {isNative} from '#/platform/detection' +import {useSession} from '#/state/session' import {atoms as a, useTheme} from '#/alf' import * as Dialog from '#/components/Dialog' import {Divider} from '#/components/Divider' import {InlineLinkText} from '#/components/Link' +import {AppModerationCause} from '#/components/Pills' import {Text} from '#/components/Typography' export {useDialogControl as useModerationDetailsDialogControl} from '#/components/Dialog' export interface ModerationDetailsDialogProps { control: Dialog.DialogOuterProps['control'] - modcause?: ModerationCause + modcause?: ModerationCause | AppModerationCause } export function ModerationDetailsDialog(props: ModerationDetailsDialogProps) { @@ -39,6 +41,7 @@ function ModerationDetailsDialogInner({ const t = useTheme() const {_} = useLingui() const desc = useModerationCauseDescription(modcause) + const {currentAccount} = useSession() let name let description @@ -105,6 +108,14 @@ function ModerationDetailsDialogInner({ } else if (modcause.type === 'hidden') { name = _(msg`Post Hidden by You`) description = _(msg`You have hidden this post.`) + } else if (modcause.type === 'reply-hidden') { + const isYou = currentAccount?.did === modcause.source.did + name = isYou + ? _(msg`Reply Hidden by You`) + : _(msg`Reply Hidden by Thread Author`) + description = isYou + ? _(msg`You hid this reply.`) + : _(msg`The author of this thread has hidden this reply.`) } else if (modcause.type === 'label') { name = desc.name description = desc.description @@ -119,12 +130,12 @@ function ModerationDetailsDialogInner({ <Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}> {name} </Text> - <Text style={[t.atoms.text, a.text_md, a.mb_lg, a.leading_snug]}> + <Text style={[t.atoms.text, a.text_md, a.leading_snug]}> {description} </Text> {modcause?.type === 'label' && ( - <> + <View style={[a.pt_lg]}> <Divider /> <Text style={[t.atoms.text, a.text_md, a.leading_snug, a.mt_lg]}> {modcause.source.type === 'user' ? ( @@ -143,7 +154,7 @@ function ModerationDetailsDialogInner({ </Trans> )} </Text> - </> + </View> )} {isNative && <View style={{height: 40}} />} diff --git a/src/components/moderation/PostAlerts.tsx b/src/components/moderation/PostAlerts.tsx index efbf18219..6c4e5f8c8 100644 --- a/src/components/moderation/PostAlerts.tsx +++ b/src/components/moderation/PostAlerts.tsx @@ -1,6 +1,6 @@ import React from 'react' import {StyleProp, ViewStyle} from 'react-native' -import {ModerationUI} from '@atproto/api' +import {ModerationCause, ModerationUI} from '@atproto/api' import {getModerationCauseKey} from '#/lib/moderation' import * as Pills from '#/components/Pills' @@ -9,13 +9,15 @@ export function PostAlerts({ modui, size = 'sm', style, + additionalCauses, }: { modui: ModerationUI size?: Pills.CommonProps['size'] includeMute?: boolean style?: StyleProp<ViewStyle> + additionalCauses?: ModerationCause[] | Pills.AppModerationCause[] }) { - if (!modui.alert && !modui.inform) { + if (!modui.alert && !modui.inform && !additionalCauses?.length) { return null } @@ -37,6 +39,14 @@ export function PostAlerts({ noBg={size === 'sm'} /> ))} + {additionalCauses?.map(cause => ( + <Pills.Label + key={getModerationCauseKey(cause)} + cause={cause} + size={size} + noBg={size === 'sm'} + /> + ))} </Pills.Row> ) } |