Bluesky app fork with some witchin' additions 💫

Rework "Who can reply" to blend more nicely into the UI (#4578)

* Rework WhoCanReply controls in threads to blend more nicely

* Fix layout

* Fix post control hitslops

* Move dialog content to separate component

---------

Co-authored-by: Dan Abramov <dan.abramov@gmail.com>

authored by

Paul Frazee
Dan Abramov
and committed by
GitHub
80197556 75aec192

+296 -168
+1
src/lib/constants.ts
··· 84 84 export const HITSLOP_10 = createHitslop(10) 85 85 export const HITSLOP_20 = createHitslop(20) 86 86 export const HITSLOP_30 = createHitslop(30) 87 + export const POST_CTRL_HITSLOP = {top: 5, bottom: 10, left: 10, right: 10} 87 88 export const BACK_HITSLOP = HITSLOP_30 88 89 export const MAX_POST_LINES = 25 89 90
+19 -19
src/view/com/post-thread/PostThreadItem.tsx
··· 25 25 import {countLines} from 'lib/strings/helpers' 26 26 import {niceDate} from 'lib/strings/time' 27 27 import {s} from 'lib/styles' 28 - import {isNative, isWeb} from 'platform/detection' 28 + import {isWeb} from 'platform/detection' 29 29 import {useSession} from 'state/session' 30 30 import {PostThreadFollowBtn} from 'view/com/post-thread/PostThreadFollowBtn' 31 31 import {atoms as a} from '#/alf' ··· 35 35 import {PostAlerts} from '../../../components/moderation/PostAlerts' 36 36 import {PostHider} from '../../../components/moderation/PostHider' 37 37 import {getTranslatorLink, isPostInLanguage} from '../../../locale/helpers' 38 - import {WhoCanReply} from '../threadgate/WhoCanReply' 38 + import {WhoCanReplyBlock, WhoCanReplyInline} from '../threadgate/WhoCanReply' 39 39 import {ErrorMessage} from '../util/error/ErrorMessage' 40 40 import {Link, TextLink} from '../util/Link' 41 41 import {formatCount} from '../util/numeric/format' ··· 340 340 </ContentHider> 341 341 <ExpandedPostDetails 342 342 post={post} 343 + isThreadAuthor={isThreadAuthor} 343 344 translatorUrl={translatorUrl} 344 345 needsTranslation={needsTranslation} 345 346 /> ··· 396 397 </View> 397 398 </View> 398 399 </View> 399 - <WhoCanReply 400 - post={post} 401 - isThreadAuthor={isThreadAuthor} 402 - style={{borderBottomWidth: isNative ? 1 : 0}} 403 - /> 404 400 </> 405 401 ) 406 402 } else { ··· 579 575 ) : undefined} 580 576 </PostHider> 581 577 </PostOuterWrapper> 582 - <WhoCanReply 583 - post={post} 584 - style={{ 585 - marginTop: 4, 586 - borderBottomWidth: 1, 587 - }} 588 - isThreadAuthor={isThreadAuthor} 589 - /> 578 + <WhoCanReplyBlock post={post} isThreadAuthor={isThreadAuthor} /> 590 579 </> 591 580 ) 592 581 } ··· 654 643 655 644 function ExpandedPostDetails({ 656 645 post, 646 + isThreadAuthor, 657 647 needsTranslation, 658 648 translatorUrl, 659 649 }: { 660 650 post: AppBskyFeedDefs.PostView 651 + isThreadAuthor: boolean 661 652 needsTranslation: boolean 662 653 translatorUrl: string 663 654 }) { ··· 670 661 }, [openLink, translatorUrl]) 671 662 672 663 return ( 673 - <View style={[s.flexRow, s.mt2, s.mb10]}> 674 - <Text style={pal.textLight}>{niceDate(post.indexedAt)}</Text> 664 + <View 665 + style={[ 666 + a.flex_row, 667 + a.align_center, 668 + a.flex_wrap, 669 + a.gap_sm, 670 + s.mt2, 671 + s.mb10, 672 + ]}> 673 + <Text style={[a.text_sm, pal.textLight]}>{niceDate(post.indexedAt)}</Text> 674 + <WhoCanReplyInline post={post} isThreadAuthor={isThreadAuthor} /> 675 675 {needsTranslation && ( 676 676 <> 677 - <Text style={pal.textLight}> &middot; </Text> 677 + <Text style={[a.text_sm, pal.textLight]}>&middot;</Text> 678 678 679 679 <Text 680 - style={pal.link} 680 + style={[a.text_sm, pal.link]} 681 681 title={_(msg`Translate`)} 682 682 onPress={onTranslatePress}> 683 683 <Trans>Translate</Trans>
+269 -142
src/view/com/threadgate/WhoCanReply.tsx
··· 11 11 import {useLingui} from '@lingui/react' 12 12 import {useQueryClient} from '@tanstack/react-query' 13 13 14 - import {useAnalytics} from '#/lib/analytics/analytics' 15 14 import {createThreadgate} from '#/lib/api' 16 15 import {until} from '#/lib/async/until' 17 - import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 18 - import {usePalette} from '#/lib/hooks/usePalette' 16 + import {HITSLOP_10} from '#/lib/constants' 19 17 import {makeListLink, makeProfileLink} from '#/lib/routes/links' 20 - import {colors} from '#/lib/styles' 21 18 import {logger} from '#/logger' 22 19 import {isNative} from '#/platform/detection' 23 20 import {useModalControls} from '#/state/modals' ··· 28 25 } from '#/state/queries/threadgate' 29 26 import {useAgent} from '#/state/session' 30 27 import * as Toast from 'view/com/util/Toast' 28 + import {atoms as a, useTheme} from '#/alf' 31 29 import {Button} from '#/components/Button' 30 + import * as Dialog from '#/components/Dialog' 31 + import {useDialogControl} from '#/components/Dialog' 32 + import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 33 + import {Earth_Stroke2_Corner0_Rounded as Earth} from '#/components/icons/Globe' 34 + import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 35 + import {Text} from '#/components/Typography' 32 36 import {TextLink} from '../util/Link' 33 - import {Text} from '../util/text/Text' 34 37 35 - export function WhoCanReply({ 36 - post, 37 - isThreadAuthor, 38 - style, 39 - }: { 38 + interface WhoCanReplyProps { 40 39 post: AppBskyFeedDefs.PostView 41 40 isThreadAuthor: boolean 42 41 style?: StyleProp<ViewStyle> 43 - }) { 44 - const {track} = useAnalytics() 42 + } 43 + 44 + export function WhoCanReplyInline({ 45 + post, 46 + isThreadAuthor, 47 + style, 48 + }: WhoCanReplyProps) { 45 49 const {_} = useLingui() 46 - const pal = usePalette('default') 47 - const agent = useAgent() 48 - const queryClient = useQueryClient() 49 - const {openModal} = useModalControls() 50 - const containerStyles = useColorSchemeStyle( 51 - { 52 - backgroundColor: pal.colors.unreadNotifBg, 53 - }, 54 - { 55 - backgroundColor: pal.colors.unreadNotifBg, 56 - }, 57 - ) 58 - const textStyles = useColorSchemeStyle( 59 - {color: colors.blue5}, 60 - {color: colors.blue1}, 61 - ) 62 - const hoverStyles = useColorSchemeStyle( 63 - { 64 - backgroundColor: colors.white, 65 - }, 66 - { 67 - backgroundColor: pal.colors.background, 68 - }, 69 - ) 70 - const settings = React.useMemo( 71 - () => threadgateViewToSettings(post.threadgate), 72 - [post], 73 - ) 74 - const isRootPost = !('reply' in post.record) 50 + const t = useTheme() 51 + const infoDialogControl = useDialogControl() 52 + const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) 75 53 76 - const onPressEdit = () => { 77 - track('Post:EditThreadgateOpened') 78 - if (isNative && Keyboard.isVisible()) { 79 - Keyboard.dismiss() 80 - } 81 - openModal({ 82 - name: 'threadgate', 83 - settings, 84 - async onConfirm(newSettings: ThreadgateSetting[]) { 85 - try { 86 - if (newSettings.length) { 87 - await createThreadgate(agent, post.uri, newSettings) 88 - } else { 89 - await agent.api.com.atproto.repo.deleteRecord({ 90 - repo: agent.session!.did, 91 - collection: 'app.bsky.feed.threadgate', 92 - rkey: new AtUri(post.uri).rkey, 93 - }) 94 - } 95 - await whenAppViewReady(agent, post.uri, res => { 96 - const thread = res.data.thread 97 - if (AppBskyFeedDefs.isThreadViewPost(thread)) { 98 - const fetchedSettings = threadgateViewToSettings( 99 - thread.post.threadgate, 100 - ) 101 - return ( 102 - JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) 103 - ) 104 - } 105 - return false 106 - }) 107 - Toast.show('Thread settings updated') 108 - queryClient.invalidateQueries({ 109 - queryKey: [POST_THREAD_RQKEY_ROOT], 110 - }) 111 - track('Post:ThreadgateEdited') 112 - } catch (err) { 113 - Toast.show( 114 - 'There was an issue. Please check your internet connection and try again.', 115 - ) 116 - logger.error('Failed to edit threadgate', {message: err}) 117 - } 118 - }, 119 - }) 54 + if (!isRootPost) { 55 + return null 56 + } 57 + if (!settings.length && !isThreadAuthor) { 58 + return null 120 59 } 121 60 61 + const isEverybody = settings.length === 0 62 + const isNobody = !!settings.find(gate => gate.type === 'nobody') 63 + const description = isEverybody 64 + ? _(msg`Everybody can reply`) 65 + : isNobody 66 + ? _(msg`Replies disabled`) 67 + : _(msg`Some people can reply`) 68 + 69 + return ( 70 + <> 71 + <Button 72 + label={ 73 + isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) 74 + } 75 + onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} 76 + hitSlop={HITSLOP_10}> 77 + {({hovered}) => ( 78 + <View style={[a.flex_row, a.align_center, a.gap_xs, style]}> 79 + <Icon 80 + color={t.palette.contrast_400} 81 + width={16} 82 + settings={settings} 83 + /> 84 + <Text 85 + style={[ 86 + a.text_sm, 87 + a.leading_tight, 88 + t.atoms.text_contrast_medium, 89 + hovered && a.underline, 90 + ]}> 91 + {description} 92 + </Text> 93 + </View> 94 + )} 95 + </Button> 96 + <InfoDialog control={infoDialogControl} post={post} settings={settings} /> 97 + </> 98 + ) 99 + } 100 + 101 + export function WhoCanReplyBlock({ 102 + post, 103 + isThreadAuthor, 104 + style, 105 + }: WhoCanReplyProps) { 106 + const {_} = useLingui() 107 + const t = useTheme() 108 + const infoDialogControl = useDialogControl() 109 + const {settings, isRootPost, onPressEdit} = useWhoCanReply(post) 110 + 122 111 if (!isRootPost) { 123 112 return null 124 113 } ··· 126 115 return null 127 116 } 128 117 118 + const isEverybody = settings.length === 0 119 + const isNobody = !!settings.find(gate => gate.type === 'nobody') 120 + const description = isEverybody 121 + ? _(msg`Everybody can reply`) 122 + : isNobody 123 + ? _(msg`Replies on this thread are disabled`) 124 + : _(msg`Some people can reply`) 125 + 129 126 return ( 130 - <View 127 + <> 128 + <Button 129 + label={ 130 + isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) 131 + } 132 + onPress={isThreadAuthor ? onPressEdit : infoDialogControl.open} 133 + hitSlop={HITSLOP_10}> 134 + {({hovered}) => ( 135 + <View 136 + style={[ 137 + a.flex_1, 138 + a.flex_row, 139 + a.align_center, 140 + a.py_sm, 141 + a.pr_lg, 142 + style, 143 + ]}> 144 + <View style={[{paddingLeft: 25, paddingRight: 18}]}> 145 + <Icon color={t.palette.contrast_300} settings={settings} /> 146 + </View> 147 + <Text 148 + style={[ 149 + a.text_sm, 150 + a.leading_tight, 151 + t.atoms.text_contrast_medium, 152 + hovered && a.underline, 153 + ]}> 154 + {description} 155 + </Text> 156 + </View> 157 + )} 158 + </Button> 159 + <InfoDialog control={infoDialogControl} post={post} settings={settings} /> 160 + </> 161 + ) 162 + } 163 + 164 + function Icon({ 165 + color, 166 + width, 167 + settings, 168 + }: { 169 + color: string 170 + width?: number 171 + settings: ThreadgateSetting[] 172 + }) { 173 + const isEverybody = settings.length === 0 174 + const isNobody = !!settings.find(gate => gate.type === 'nobody') 175 + const IconComponent = isEverybody ? Earth : isNobody ? CircleBanSign : Group 176 + return <IconComponent fill={color} width={width} /> 177 + } 178 + 179 + function InfoDialog({ 180 + control, 181 + post, 182 + settings, 183 + }: { 184 + control: Dialog.DialogControlProps 185 + post: AppBskyFeedDefs.PostView 186 + settings: ThreadgateSetting[] 187 + }) { 188 + return ( 189 + <Dialog.Outer control={control}> 190 + <Dialog.Handle /> 191 + <InfoDialogInner post={post} settings={settings} /> 192 + </Dialog.Outer> 193 + ) 194 + } 195 + 196 + function InfoDialogInner({ 197 + post, 198 + settings, 199 + }: { 200 + post: AppBskyFeedDefs.PostView 201 + settings: ThreadgateSetting[] 202 + }) { 203 + const {_} = useLingui() 204 + return ( 205 + <Dialog.ScrollableInner 206 + label={_(msg`Who can reply dialog`)} 207 + style={[{width: 'auto', maxWidth: 400, minWidth: 200}]}> 208 + <View style={[a.gap_sm]}> 209 + <Text style={[a.font_bold, a.text_xl]}> 210 + <Trans>Who can reply?</Trans> 211 + </Text> 212 + <Rules post={post} settings={settings} /> 213 + </View> 214 + </Dialog.ScrollableInner> 215 + ) 216 + } 217 + 218 + function Rules({ 219 + post, 220 + settings, 221 + }: { 222 + post: AppBskyFeedDefs.PostView 223 + settings: ThreadgateSetting[] 224 + }) { 225 + const t = useTheme() 226 + return ( 227 + <Text 131 228 style={[ 132 - { 133 - flexDirection: 'row', 134 - alignItems: 'center', 135 - gap: 10, 136 - paddingLeft: 18, 137 - paddingRight: 14, 138 - paddingVertical: 10, 139 - borderTopWidth: 1, 140 - }, 141 - pal.border, 142 - containerStyles, 143 - style, 229 + a.text_md, 230 + a.leading_tight, 231 + a.flex_wrap, 232 + t.atoms.text_contrast_medium, 144 233 ]}> 145 - <View style={{flex: 1, paddingVertical: 6}}> 146 - <Text type="sm" style={[{flexWrap: 'wrap'}, textStyles]}> 147 - {!settings.length ? ( 148 - <Trans>Everybody can reply.</Trans> 149 - ) : settings[0].type === 'nobody' ? ( 150 - <Trans>Replies to this thread are disabled.</Trans> 151 - ) : ( 152 - <Trans> 153 - Only{' '} 154 - {settings.map((rule, i) => ( 155 - <React.Fragment key={`rule-${i}`}> 156 - <Rule 157 - rule={rule} 158 - post={post} 159 - lists={post.threadgate!.lists} 160 - /> 161 - <Separator key={`sep-${i}`} i={i} length={settings.length} /> 162 - </React.Fragment> 163 - ))}{' '} 164 - can reply. 165 - </Trans> 166 - )} 167 - </Text> 168 - </View> 169 - {isThreadAuthor && ( 170 - <View> 171 - <Button label={_(msg`Edit`)} onPress={onPressEdit}> 172 - {({hovered}) => ( 173 - <View 174 - style={[ 175 - hovered && hoverStyles, 176 - {paddingVertical: 6, paddingHorizontal: 8, borderRadius: 8}, 177 - ]}> 178 - <Text type="sm" style={pal.link}> 179 - <Trans>Edit</Trans> 180 - </Text> 181 - </View> 182 - )} 183 - </Button> 184 - </View> 234 + {!settings.length ? ( 235 + <Trans>Everybody can reply</Trans> 236 + ) : settings[0].type === 'nobody' ? ( 237 + <Trans>Replies to this thread are disabled</Trans> 238 + ) : ( 239 + <Trans> 240 + Only{' '} 241 + {settings.map((rule, i) => ( 242 + <> 243 + <Rule 244 + key={`rule-${i}`} 245 + rule={rule} 246 + post={post} 247 + lists={post.threadgate!.lists} 248 + /> 249 + <Separator key={`sep-${i}`} i={i} length={settings.length} /> 250 + </> 251 + ))}{' '} 252 + can reply 253 + </Trans> 185 254 )} 186 - </View> 255 + </Text> 187 256 ) 188 257 } 189 258 ··· 196 265 post: AppBskyFeedDefs.PostView 197 266 lists: AppBskyGraphDefs.ListViewBasic[] | undefined 198 267 }) { 199 - const pal = usePalette('default') 268 + const t = useTheme() 200 269 if (rule.type === 'mention') { 201 270 return <Trans>mentioned users</Trans> 202 271 } ··· 208 277 type="sm" 209 278 href={makeProfileLink(post.author)} 210 279 text={`@${post.author.handle}`} 211 - style={pal.link} 280 + style={{color: t.palette.primary_500}} 212 281 /> 213 282 </Trans> 214 283 ) ··· 223 292 type="sm" 224 293 href={makeListLink(listUrip.hostname, listUrip.rkey)} 225 294 text={list.name} 226 - style={pal.link} 295 + style={{color: t.palette.primary_500}} 227 296 />{' '} 228 297 members 229 298 </Trans> ··· 244 313 ) 245 314 } 246 315 return <>, </> 316 + } 317 + 318 + function useWhoCanReply(post: AppBskyFeedDefs.PostView) { 319 + const agent = useAgent() 320 + const queryClient = useQueryClient() 321 + const {openModal} = useModalControls() 322 + 323 + const settings = React.useMemo( 324 + () => threadgateViewToSettings(post.threadgate), 325 + [post], 326 + ) 327 + const isRootPost = !('reply' in post.record) 328 + 329 + const onPressEdit = () => { 330 + if (isNative && Keyboard.isVisible()) { 331 + Keyboard.dismiss() 332 + } 333 + openModal({ 334 + name: 'threadgate', 335 + settings, 336 + async onConfirm(newSettings: ThreadgateSetting[]) { 337 + try { 338 + if (newSettings.length) { 339 + await createThreadgate(agent, post.uri, newSettings) 340 + } else { 341 + await agent.api.com.atproto.repo.deleteRecord({ 342 + repo: agent.session!.did, 343 + collection: 'app.bsky.feed.threadgate', 344 + rkey: new AtUri(post.uri).rkey, 345 + }) 346 + } 347 + await whenAppViewReady(agent, post.uri, res => { 348 + const thread = res.data.thread 349 + if (AppBskyFeedDefs.isThreadViewPost(thread)) { 350 + const fetchedSettings = threadgateViewToSettings( 351 + thread.post.threadgate, 352 + ) 353 + return ( 354 + JSON.stringify(fetchedSettings) === JSON.stringify(newSettings) 355 + ) 356 + } 357 + return false 358 + }) 359 + Toast.show('Thread settings updated') 360 + queryClient.invalidateQueries({ 361 + queryKey: [POST_THREAD_RQKEY_ROOT], 362 + }) 363 + } catch (err) { 364 + Toast.show( 365 + 'There was an issue. Please check your internet connection and try again.', 366 + ) 367 + logger.error('Failed to edit threadgate', {message: err}) 368 + } 369 + }, 370 + }) 371 + } 372 + 373 + return {settings, isRootPost, onPressEdit} 247 374 } 248 375 249 376 async function whenAppViewReady(
+5 -5
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 15 15 import {msg, plural} from '@lingui/macro' 16 16 import {useLingui} from '@lingui/react' 17 17 18 - import {HITSLOP_10, HITSLOP_20} from '#/lib/constants' 18 + import {POST_CTRL_HITSLOP} from '#/lib/constants' 19 19 import {useHaptics} from '#/lib/haptics' 20 20 import {makeProfileLink} from '#/lib/routes/links' 21 21 import {shareUrl} from '#/lib/sharing' ··· 215 215 other: 'Reply (# replies)', 216 216 })} 217 217 accessibilityHint="" 218 - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> 218 + hitSlop={POST_CTRL_HITSLOP}> 219 219 <Bubble 220 220 style={[defaultCtrlColor, {pointerEvents: 'none'}]} 221 221 width={big ? 22 : 18} ··· 258 258 }) 259 259 } 260 260 accessibilityHint="" 261 - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> 261 + hitSlop={POST_CTRL_HITSLOP}> 262 262 {post.viewer?.like ? ( 263 263 <HeartIconFilled style={s.likeColor} width={big ? 22 : 18} /> 264 264 ) : ( ··· 299 299 }} 300 300 accessibilityLabel={_(msg`Share`)} 301 301 accessibilityHint="" 302 - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> 302 + hitSlop={POST_CTRL_HITSLOP}> 303 303 <ArrowOutOfBox 304 304 style={[defaultCtrlColor, {pointerEvents: 'none'}]} 305 305 width={22} ··· 325 325 record={record} 326 326 richText={richText} 327 327 style={{padding: 5}} 328 - hitSlop={big ? HITSLOP_20 : HITSLOP_10} 328 + hitSlop={POST_CTRL_HITSLOP} 329 329 timestamp={post.indexedAt} 330 330 /> 331 331 </View>
+2 -2
src/view/com/util/post-ctrls/RepostButton.tsx
··· 3 3 import {msg, plural} from '@lingui/macro' 4 4 import {useLingui} from '@lingui/react' 5 5 6 - import {HITSLOP_10, HITSLOP_20} from '#/lib/constants' 6 + import {POST_CTRL_HITSLOP} from '#/lib/constants' 7 7 import {useHaptics} from '#/lib/haptics' 8 8 import {useRequireAuth} from '#/state/session' 9 9 import {atoms as a, useTheme} from '#/alf' ··· 67 67 shape="round" 68 68 variant="ghost" 69 69 color="secondary" 70 - hitSlop={big ? HITSLOP_20 : HITSLOP_10}> 70 + hitSlop={POST_CTRL_HITSLOP}> 71 71 <Repost style={color} width={big ? 22 : 18} /> 72 72 {typeof repostCount !== 'undefined' && repostCount > 0 ? ( 73 73 <Text