diff options
-rw-r--r-- | src/lib/constants.ts | 1 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 38 | ||||
-rw-r--r-- | src/view/com/threadgate/WhoCanReply.tsx | 411 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/PostCtrls.tsx | 10 | ||||
-rw-r--r-- | src/view/com/util/post-ctrls/RepostButton.tsx | 4 |
5 files changed, 296 insertions, 168 deletions
diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 05d1591f5..e0b899800 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -84,6 +84,7 @@ export const createHitslop = (size: number): Insets => ({ export const HITSLOP_10 = createHitslop(10) export const HITSLOP_20 = createHitslop(20) export const HITSLOP_30 = createHitslop(30) +export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} export const BACK_HITSLOP = HITSLOP_30 export const MAX_POST_LINES = 25 diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 6d03029d7..92b529db7 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -25,7 +25,7 @@ import {sanitizeHandle} from 'lib/strings/handles' import {countLines} from 'lib/strings/helpers' import {niceDate} from 'lib/strings/time' import {s} from 'lib/styles' -import {isNative, isWeb} from 'platform/detection' +import {isWeb} from 'platform/detection' import {useSession} from 'state/session' import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' import {atoms as a} from '#/alf' @@ -35,7 +35,7 @@ import {LabelsOnMyPost} from '../../../components/moderation/LabelsOnMe' import {PostAlerts} from '../../../components/moderation/PostAlerts' import {PostHider} from '../../../components/moderation/PostHider' import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' -import {WhoCanReply} from '../threadgate/WhoCanReply' +import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply' import {ErrorMessage} from '../util/error/ErrorMessage' import {Link, TextLink} from '../util/Link' import {formatCount} from '../util/numeric/format' @@ -340,6 +340,7 @@ let PostThreadItemLoaded = ({ </ContentHider> <ExpandedPostDetails post={post} + isThreadAuthor={isThreadAuthor} translatorUrl={translatorUrl} needsTranslation={needsTranslation} /> @@ -396,11 +397,6 @@ let PostThreadItemLoaded = ({ </View> </View> </View> - <WhoCanReply - post={post} - isThreadAuthor={isThreadAuthor} - style={{borderBottomWidth: isNative ? 1 : 0}} - /> </> ) } else { @@ -579,14 +575,7 @@ let PostThreadItemLoaded = ({ ) : undefined} </PostHider> </PostOuterWrapper> - <WhoCanReply - post={post} - style={{ - marginTop: 4, - borderBottomWidth: 1, - }} - isThreadAuthor={isThreadAuthor} - /> + <WhoCanReplyBlock post={post} isThreadAuthor={isThreadAuthor} /> </> ) } @@ -654,10 +643,12 @@ function PostOuterWrapper({ function ExpandedPostDetails({ post, + isThreadAuthor, needsTranslation, translatorUrl, }: { post: AppBskyFeedDefs.PostView + isThreadAuthor: boolean needsTranslation: boolean translatorUrl: string }) { @@ -670,14 +661,23 @@ function ExpandedPostDetails({ }, [openLink, translatorUrl]) return ( - <View style={[s.flexRow, s.mt2, s.mb10]}> - <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> + <View + style={[ + a.flex_row, + a.align_center, + a.flex_wrap, + a.gap_sm, + s.mt2, + s.mb10, + ]}> + <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> + <WhoCanReplyInline post={post} isThreadAuthor={isThreadAuthor} /> {needsTranslation && ( <> - <Text style={pal.textLight}> · </Text> + <Text style={[a.text_sm, pal.textLight]}>·</Text> <Text - style={pal.link} + style={[a.text_sm, pal.link]} title={_(msg`Translate`)} onPress={onTranslatePress}> <Trans>Translate</Trans> diff --git a/src/view/com/threadgate/WhoCanReply.tsx b/src/view/com/threadgate/WhoCanReply.tsx index 7e3528d92..3f9970f5f 100644 --- a/src/view/com/threadgate/WhoCanReply.tsx +++ b/src/view/com/threadgate/WhoCanReply.tsx @@ -11,13 +11,10 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useQueryClient} from '@tanstack/react-query' -import {useAnalytics} from '#/lib/analytics/analytics' import {createThreadgate} from '#/lib/api' import {until} from '#/lib/async/until' -import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' -import {usePalette} from '#/lib/hooks/usePalette' +import {HITSLOP_10} from '#/lib/constants' import {makeListLink, makeProfileLink} from '#/lib/routes/links' -import {colors} from '#/lib/styles' import {logger} from '#/logger' import {isNative} from '#/platform/detection' import {useModalControls} from '#/state/modals' @@ -28,97 +25,89 @@ import { } 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' -import {Text} from '../util/text/Text' -export function WhoCanReply({ - post, - isThreadAuthor, - style, -}: { +interface WhoCanReplyProps { post: AppBskyFeedDefs.PostView isThreadAuthor: boolean style?: StyleProp<ViewStyle> -}) { - const {track} = useAnalytics() +} + +export function WhoCanReplyInline({ + post, + isThreadAuthor, + style, +}: WhoCanReplyProps) { const {_} = useLingui() - const pal = usePalette('default') - const agent = useAgent() - const queryClient = useQueryClient() - const {openModal} = useModalControls() - const containerStyles = useColorSchemeStyle( - { - backgroundColor: pal.colors.unreadNotifBg, - }, - { - backgroundColor: pal.colors.unreadNotifBg, - }, - ) - const textStyles = useColorSchemeStyle( - {color: colors.blue5}, - {color: colors.blue1}, - ) - const hoverStyles = useColorSchemeStyle( - { - backgroundColor: colors.white, - }, - { - backgroundColor: pal.colors.background, - }, - ) - const settings = React.useMemo( - () => threadgateViewToSettings(post.threadgate), - [post], - ) - const isRootPost = !('reply' in post.record) + const t = useTheme() + const infoDialogControl = useDialogControl() + const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) - const onPressEdit = () => { - track('Post:EditThreadgateOpened') - 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], - }) - track('Post:ThreadgateEdited') - } catch (err) { - Toast.show( - 'There was an issue. Please check your internet connection and try again.', - ) - logger.error('Failed to edit threadgate', {message: err}) - } - }, - }) + 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 ( + <> + <Button + label={ + isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) + } + onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} + hitSlop={HITSLOP_10}> + {({hovered}) => ( + <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> + <Icon + color={t.palette.contrast_400} + width={16} + settings={settings} + /> + <Text + style={[ + a.text_sm, + a.leading_tight, + t.atoms.text_contrast_medium, + hovered && a.underline, + ]}> + {description} + </Text> + </View> + )} + </Button> + <InfoDialog control={infoDialogControl} post={post} settings={settings} /> + </> + ) +} + +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 } @@ -126,64 +115,144 @@ export function WhoCanReply({ 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 ( - <View - style={[ - { - flexDirection: 'row', - alignItems: 'center', - gap: 10, - paddingLeft: 18, - paddingRight: 14, - paddingVertical: 10, - borderTopWidth: 1, - }, - pal.border, - containerStyles, - style, - ]}> - <View style={{flex: 1, paddingVertical: 6}}> - <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> - {!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) => ( - <React.Fragment key={`rule-${i}`}> - <Rule - rule={rule} - post={post} - lists={post.threadgate!.lists} - /> - <Separator key={`sep-${i}`} i={i} length={settings.length} /> - </React.Fragment> - ))}{' '} - can reply. - </Trans> - )} + <> + <Button + label={ + isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) + } + onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} + hitSlop={HITSLOP_10}> + {({hovered}) => ( + <View + style={[ + a.flex_1, + a.flex_row, + a.align_center, + a.py_sm, + a.pr_lg, + style, + ]}> + <View style={[{paddingLeft: 25, paddingRight: 18}]}> + <Icon color={t.palette.contrast_300} settings={settings} /> + </View> + <Text + style={[ + a.text_sm, + a.leading_tight, + t.atoms.text_contrast_medium, + hovered && a.underline, + ]}> + {description} + </Text> + </View> + )} + </Button> + <InfoDialog control={infoDialogControl} post={post} settings={settings} /> + </> + ) +} + +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 <IconComponent fill={color} width={width} /> +} + +function InfoDialog({ + control, + post, + settings, +}: { + control: Dialog.DialogControlProps + post: AppBskyFeedDefs.PostView + settings: ThreadgateSetting[] +}) { + return ( + <Dialog.Outer control={control}> + <Dialog.Handle /> + <InfoDialogInner post={post} settings={settings} /> + </Dialog.Outer> + ) +} + +function InfoDialogInner({ + 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> - {isThreadAuthor && ( - <View> - <Button label={_(msg`Edit`)} onPress={onPressEdit}> - {({hovered}) => ( - <View - style={[ - hovered && hoverStyles, - {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8}, - ]}> - <Text type="sm" style={pal.link}> - <Trans>Edit</Trans> - </Text> - </View> - )} - </Button> - </View> + </Dialog.ScrollableInner> + ) +} + +function Rules({ + post, + settings, +}: { + post: AppBskyFeedDefs.PostView + settings: ThreadgateSetting[] +}) { + 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> )} - </View> + </Text> ) } @@ -196,7 +265,7 @@ function Rule({ post: AppBskyFeedDefs.PostView lists: AppBskyGraphDefs.ListViewBasic[] | undefined }) { - const pal = usePalette('default') + const t = useTheme() if (rule.type === 'mention') { return <Trans>mentioned users</Trans> } @@ -208,7 +277,7 @@ function Rule({ type="sm" href={makeProfileLink(post.author)} text={`@${post.author.handle}`} - style={pal.link} + style={{color: t.palette.primary_500}} /> </Trans> ) @@ -223,7 +292,7 @@ function Rule({ type="sm" href={makeListLink(listUrip.hostname, listUrip.rkey)} text={list.name} - style={pal.link} + style={{color: t.palette.primary_500}} />{' '} members </Trans> @@ -246,6 +315,64 @@ function Separator({i, length}: {i: number; length: number}) { 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, diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 55fb4a334..472ce4043 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -15,7 +15,7 @@ import { import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {HITSLOP_10, HITSLOP_20} from '#/lib/constants' +import {POST_CTRL_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {makeProfileLink} from '#/lib/routes/links' import {shareUrl} from '#/lib/sharing' @@ -215,7 +215,7 @@ let PostCtrls = ({ other: 'Reply (# replies)', })} accessibilityHint="" - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={POST_CTRL_HITSLOP}> <Bubble style={[defaultCtrlColor, {pointerEvents: 'none'}]} width={big ? 22 : 18} @@ -258,7 +258,7 @@ let PostCtrls = ({ }) } accessibilityHint="" - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={POST_CTRL_HITSLOP}> {post.viewer?.like ? ( <HeartIconFilled style={s.likeColor} width={big ? 22 : 18} /> ) : ( @@ -299,7 +299,7 @@ let PostCtrls = ({ }} accessibilityLabel={_(msg`Share`)} accessibilityHint="" - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={POST_CTRL_HITSLOP}> <ArrowOutOfBox style={[defaultCtrlColor, {pointerEvents: 'none'}]} width={22} @@ -325,7 +325,7 @@ let PostCtrls = ({ record={record} richText={richText} style={{padding: 5}} - hitSlop={big ? HITSLOP_20 : HITSLOP_10} + hitSlop={POST_CTRL_HITSLOP} timestamp={post.indexedAt} /> </View> diff --git a/src/view/com/util/post-ctrls/RepostButton.tsx b/src/view/com/util/post-ctrls/RepostButton.tsx index 10bc369b8..d49cda442 100644 --- a/src/view/com/util/post-ctrls/RepostButton.tsx +++ b/src/view/com/util/post-ctrls/RepostButton.tsx @@ -3,7 +3,7 @@ import {View} from 'react-native' import {msg, plural} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {HITSLOP_10, HITSLOP_20} from '#/lib/constants' +import {POST_CTRL_HITSLOP} from '#/lib/constants' import {useHaptics} from '#/lib/haptics' import {useRequireAuth} from '#/state/session' import {atoms as a, useTheme} from '#/alf' @@ -67,7 +67,7 @@ let RepostButton = ({ shape="round" variant="ghost" color="secondary" - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> + hitSlop={POST_CTRL_HITSLOP}> <Repost style={color} width={big ? 22 : 18} /> {typeof repostCount !== 'undefined' && repostCount > 0 ? ( <Text |