From f769564edfea3ec6406c49ef639685d942e14e09 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 24 Jun 2024 10:11:43 -0700 Subject: Remove the 'Who can reply' element except when viewing root, and add "edit" (#4615) * Remove the 'Who can reply' element except when viewing root, and add the edit text to authors * Switch to icon --- src/components/WhoCanReply.tsx | 324 +++++++++++++++++++++++ src/view/com/post-thread/PostThreadItem.tsx | 305 +++++++++++----------- src/view/com/threadgate/WhoCanReply.tsx | 391 ---------------------------- 3 files changed, 474 insertions(+), 546 deletions(-) create mode 100644 src/components/WhoCanReply.tsx delete mode 100644 src/view/com/threadgate/WhoCanReply.tsx (limited to 'src') diff --git a/src/components/WhoCanReply.tsx b/src/components/WhoCanReply.tsx new file mode 100644 index 000000000..cd171a0a4 --- /dev/null +++ b/src/components/WhoCanReply.tsx @@ -0,0 +1,324 @@ +import React from 'react' +import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedGetPostThread, + 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 {useModalControls} from '#/state/modals' +import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' +import { + ThreadgateSetting, + threadgateViewToSettings, +} 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 {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 {Text} from '#/components/Typography' +import {TextLink} from '../view/com/util/Link' +import {PencilLine_Stroke2_Corner0_Rounded as PencilLine} from './icons/Pencil' + +interface WhoCanReplyProps { + post: AppBskyFeedDefs.PostView + isThreadAuthor: boolean + style?: StyleProp +} + +export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { + const {_} = useLingui() + const t = useTheme() + const infoDialogControl = useDialogControl() + const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) + + if (!isRootPost) { + return null + } + if (!settings.length && !isThreadAuthor) { + return null + } + + const isEverybody = settings.length === 0 + const isNobody = !!settings.find(gate => gate.type === 'nobody') + const description = isEverybody + ? _(msg`Everybody can reply`) + : isNobody + ? _(msg`Replies disabled`) + : _(msg`Some people can reply`) + + return ( + <> + + + + ) +} + +function Icon({ + color, + width, + settings, +}: { + color: string + width?: number + settings: ThreadgateSetting[] +}) { + const isEverybody = settings.length === 0 + const isNobody = !!settings.find(gate => gate.type === 'nobody') + const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group + return +} + +export function WhoCanReplyDialog({ + control, + post, +}: { + control: Dialog.DialogControlProps + post: AppBskyFeedDefs.PostView +}) { + return ( + + + + + ) +} + +function WhoCanReplyDialogInner({post}: {post: AppBskyFeedDefs.PostView}) { + const {_} = useLingui() + const {settings} = useWhoCanReply(post) + return ( + + + + Who can reply? + + + + + ) +} + +function Rules({ + post, + settings, +}: { + post: AppBskyFeedDefs.PostView + settings: ThreadgateSetting[] +}) { + const t = useTheme() + return ( + + {!settings.length ? ( + Everybody can reply + ) : settings[0].type === 'nobody' ? ( + Replies to this thread are disabled + ) : ( + + Only{' '} + {settings.map((rule, i) => ( + <> + + + + ))}{' '} + can reply + + )} + + ) +} + +function Rule({ + rule, + post, + lists, +}: { + rule: ThreadgateSetting + post: AppBskyFeedDefs.PostView + lists: AppBskyGraphDefs.ListViewBasic[] | undefined +}) { + const t = useTheme() + if (rule.type === 'mention') { + return mentioned users + } + if (rule.type === 'following') { + return ( + + users followed by{' '} + + + ) + } + if (rule.type === 'list') { + const list = lists?.find(l => l.uri === rule.list) + if (list) { + const listUrip = new AtUri(list.uri) + return ( + + {' '} + members + + ) + } + } +} + +function Separator({i, length}: {i: number; length: number}) { + if (length < 2 || i === length - 1) { + return null + } + if (i === length - 2) { + return ( + <> + {length > 2 ? ',' : ''} and{' '} + + ) + } + return <>, +} + +function useWhoCanReply(post: AppBskyFeedDefs.PostView) { + const agent = useAgent() + const queryClient = useQueryClient() + const {openModal} = useModalControls() + + const settings = React.useMemo( + () => threadgateViewToSettings(post.threadgate), + [post], + ) + const isRootPost = !('reply' in post.record) + + const onPressEdit = () => { + if (isNative && Keyboard.isVisible()) { + Keyboard.dismiss() + } + openModal({ + name: 'threadgate', + settings, + async onConfirm(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('Thread settings updated') + queryClient.invalidateQueries({ + queryKey: [POST_THREAD_RQKEY_ROOT], + }) + } catch (err) { + Toast.show( + 'There was an issue. Please check your internet connection and try again.', + ) + logger.error('Failed to edit threadgate', {message: err}) + } + }, + }) + } + + return {settings, isRootPost, onPressEdit} +} + +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/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 92b529db7..46c6c958e 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -34,8 +34,8 @@ import {ContentHider} from '../../../components/moderation/ContentHider' import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostHider} from '../../../components/moderation/PostHider' +import {WhoCanReply} from '../../../components/WhoCanReply' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply' import {ErrorMessage} from '../util/error/ErrorMessage' import {Link, TextLink} from '../util/Link' import {formatCount} from '../util/numeric/format' @@ -406,177 +406,172 @@ let PostThreadItemLoaded = ({ const isThreadedChildAdjacentBot = isThreadedChild && nextPost?.ctx.depth === depth return ( - <> - - - - - {!isThreadedChild && showParentReplyLine && ( + + + + + {!isThreadedChild && showParentReplyLine && ( + + )} + + + + + {/* If we are in threaded mode, the avatar is rendered in PostMeta */} + {!isThreadedChild && ( + + + + {showChildReplyLine && ( )} - + )} - {/* If we are in threaded mode, the avatar is rendered in PostMeta */} - {!isThreadedChild && ( - - - - {showChildReplyLine && ( - - )} - - )} - - + - - - - {richText?.text ? ( - - - - ) : undefined} - {limitLines ? ( - + + + {richText?.text ? ( + + - ) : undefined} - {post.embed && ( - - - - )} - + ) : undefined} + {limitLines ? ( + - + ) : undefined} + {post.embed && ( + + + + )} + - {hasMore ? ( - - - More - - - - ) : undefined} - - - - + + {hasMore ? ( + + + More + + + + ) : undefined} + + ) } } @@ -671,7 +666,7 @@ function ExpandedPostDetails({ s.mb10, ]}> {niceDate(post.indexedAt)} - + {needsTranslation && ( <> · diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx deleted file mode 100644 index 3f9970f5f..000000000 --- a/src/view/com/threadgate/WhoCanReply.tsx +++ /dev/null @@ -1,391 +0,0 @@ -import React from 'react' -import {Keyboard, StyleProp, View, ViewStyle} from 'react-native' -import { - AppBskyFeedDefs, - AppBskyFeedGetPostThread, - 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 {useModalControls} from '#/state/modals' -import {RQKEY_ROOT as POST_THREAD_RQKEY_ROOT} from '#/state/queries/post-thread' -import { - ThreadgateSetting, - threadgateViewToSettings, -} 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 {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 {Text} from '#/components/Typography' -import {TextLink} from '../util/Link' - -interface WhoCanReplyProps { - post: AppBskyFeedDefs.PostView - isThreadAuthor: boolean - style?: StyleProp -} - -export function WhoCanReplyInline({ - post, - isThreadAuthor, - style, -}: WhoCanReplyProps) { - const {_} = useLingui() - const t = useTheme() - const infoDialogControl = useDialogControl() - const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) - - if (!isRootPost) { - return null - } - if (!settings.length && !isThreadAuthor) { - return null - } - - const isEverybody = settings.length === 0 - const isNobody = !!settings.find(gate => gate.type === 'nobody') - const description = isEverybody - ? _(msg`Everybody can reply`) - : isNobody - ? _(msg`Replies disabled`) - : _(msg`Some people can reply`) - - return ( - <> - - - - ) -} - -export function WhoCanReplyBlock({ - post, - isThreadAuthor, - style, -}: WhoCanReplyProps) { - const {_} = useLingui() - const t = useTheme() - const infoDialogControl = useDialogControl() - const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) - - if (!isRootPost) { - return null - } - if (!settings.length && !isThreadAuthor) { - return null - } - - const isEverybody = settings.length === 0 - const isNobody = !!settings.find(gate => gate.type === 'nobody') - const description = isEverybody - ? _(msg`Everybody can reply`) - : isNobody - ? _(msg`Replies on this thread are disabled`) - : _(msg`Some people can reply`) - - return ( - <> - - - - ) -} - -function Icon({ - color, - width, - settings, -}: { - color: string - width?: number - settings: ThreadgateSetting[] -}) { - const isEverybody = settings.length === 0 - const isNobody = !!settings.find(gate => gate.type === 'nobody') - const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group - return -} - -function InfoDialog({ - control, - post, - settings, -}: { - control: Dialog.DialogControlProps - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] -}) { - return ( - - - - - ) -} - -function InfoDialogInner({ - post, - settings, -}: { - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] -}) { - const {_} = useLingui() - return ( - - - - Who can reply? - - - - - ) -} - -function Rules({ - post, - settings, -}: { - post: AppBskyFeedDefs.PostView - settings: ThreadgateSetting[] -}) { - const t = useTheme() - return ( - - {!settings.length ? ( - Everybody can reply - ) : settings[0].type === 'nobody' ? ( - Replies to this thread are disabled - ) : ( - - Only{' '} - {settings.map((rule, i) => ( - <> - - - - ))}{' '} - can reply - - )} - - ) -} - -function Rule({ - rule, - post, - lists, -}: { - rule: ThreadgateSetting - post: AppBskyFeedDefs.PostView - lists: AppBskyGraphDefs.ListViewBasic[] | undefined -}) { - const t = useTheme() - if (rule.type === 'mention') { - return mentioned users - } - if (rule.type === 'following') { - return ( - - users followed by{' '} - - - ) - } - if (rule.type === 'list') { - const list = lists?.find(l => l.uri === rule.list) - if (list) { - const listUrip = new AtUri(list.uri) - return ( - - {' '} - members - - ) - } - } -} - -function Separator({i, length}: {i: number; length: number}) { - if (length < 2 || i === length - 1) { - return null - } - if (i === length - 2) { - return ( - <> - {length > 2 ? ',' : ''} and{' '} - - ) - } - return <>, -} - -function useWhoCanReply(post: AppBskyFeedDefs.PostView) { - const agent = useAgent() - const queryClient = useQueryClient() - const {openModal} = useModalControls() - - const settings = React.useMemo( - () => threadgateViewToSettings(post.threadgate), - [post], - ) - const isRootPost = !('reply' in post.record) - - const onPressEdit = () => { - if (isNative && Keyboard.isVisible()) { - Keyboard.dismiss() - } - openModal({ - name: 'threadgate', - settings, - async onConfirm(newSettings: ThreadgateSetting[]) { - 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('Thread settings updated') - queryClient.invalidateQueries({ - queryKey: [POST_THREAD_RQKEY_ROOT], - }) - } catch (err) { - Toast.show( - 'There was an issue. Please check your internet connection and try again.', - ) - logger.error('Failed to edit threadgate', {message: err}) - } - }, - }) - } - - return {settings, isRootPost, onPressEdit} -} - -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, - }), - ) -} -- cgit 1.4.1