Bluesky app fork with some witchin' additions 💫 witchsky.app
bluesky fork client
122
fork

Configure Feed

Select the types of activity you want to include in your feed.

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