Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 379 lines 10 kB view raw
1import {Fragment, useMemo, useRef} from 'react' 2import { 3 Keyboard, 4 Platform, 5 type StyleProp, 6 View, 7 type ViewStyle, 8} from 'react-native' 9import { 10 type AppBskyFeedDefs, 11 AppBskyFeedPost, 12 type AppBskyGraphDefs, 13 AtUri, 14} from '@atproto/api' 15import {msg} from '@lingui/core/macro' 16import {useLingui} from '@lingui/react' 17import {Trans} from '@lingui/react/macro' 18 19import {HITSLOP_10} from '#/lib/constants' 20import {makeListLink, makeProfileLink} from '#/lib/routes/links' 21import { 22 type ThreadgateAllowUISetting, 23 threadgateViewToAllowUISetting, 24} from '#/state/queries/threadgate' 25import {atoms as a, native, useTheme, web} from '#/alf' 26import {Button, ButtonText} from '#/components/Button' 27import * as Dialog from '#/components/Dialog' 28import {useDialogControl} from '#/components/Dialog' 29import { 30 PostInteractionSettingsDialog, 31 usePrefetchPostInteractionSettings, 32} from '#/components/dialogs/PostInteractionSettingsDialog' 33import {TinyChevronBottom_Stroke2_Corner0_Rounded as TinyChevronDownIcon} from '#/components/icons/Chevron' 34import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSignIcon} from '#/components/icons/CircleBanSign' 35import {Earth_Stroke2_Corner0_Rounded as EarthIcon} from '#/components/icons/Globe' 36import {Group3_Stroke2_Corner0_Rounded as GroupIcon} from '#/components/icons/Group' 37import {InlineLinkText} from '#/components/Link' 38import {Text} from '#/components/Typography' 39import {useAnalytics} from '#/analytics' 40import {IS_NATIVE} from '#/env' 41import * as bsky from '#/types/bsky' 42 43interface WhoCanReplyProps { 44 post: AppBskyFeedDefs.PostView 45 isThreadAuthor: boolean 46 style?: StyleProp<ViewStyle> 47} 48 49export function WhoCanReply({post, isThreadAuthor, style}: WhoCanReplyProps) { 50 const t = useTheme() 51 const ax = useAnalytics() 52 const {_} = useLingui() 53 const infoDialogControl = useDialogControl() 54 const editDialogControl = useDialogControl() 55 56 /* 57 * `WhoCanReply` is only used for root posts atm, in case this changes 58 * unexpectedly, we should check to make sure it's for sure the root URI. 59 */ 60 const rootUri = 61 bsky.dangerousIsType<AppBskyFeedPost.Record>( 62 post.record, 63 AppBskyFeedPost.isRecord, 64 ) && post.record.reply?.root 65 ? post.record.reply.root.uri 66 : post.uri 67 const settings = useMemo(() => { 68 return threadgateViewToAllowUISetting(post.threadgate) 69 }, [post.threadgate]) 70 71 const prefetchPostInteractionSettings = usePrefetchPostInteractionSettings({ 72 postUri: post.uri, 73 rootPostUri: rootUri, 74 }) 75 const prefetchPromise = useRef<Promise<void>>(Promise.resolve()) 76 77 const prefetch = () => { 78 prefetchPromise.current = prefetchPostInteractionSettings() 79 } 80 81 const anyoneCanReply = 82 settings.length === 1 && settings[0].type === 'everybody' 83 const noOneCanReply = settings.length === 1 && settings[0].type === 'nobody' 84 const description = anyoneCanReply 85 ? _(msg`Everybody can reply`) 86 : noOneCanReply 87 ? _(msg`Replies disabled`) 88 : _(msg`Some people can reply`) 89 90 const onPressOpen = () => { 91 if (IS_NATIVE && Keyboard.isVisible()) { 92 Keyboard.dismiss() 93 } 94 if (isThreadAuthor) { 95 ax.metric('thread:click:editOwnThreadgate', {}) 96 97 // wait on prefetch if it manages to resolve in under 200ms 98 // otherwise, proceed immediately and show the spinner -sfn 99 Promise.race([ 100 prefetchPromise.current, 101 new Promise(res => setTimeout(res, 200)), 102 ]).finally(() => { 103 editDialogControl.open() 104 }) 105 } else { 106 ax.metric('thread:click:viewSomeoneElsesThreadgate', {}) 107 108 infoDialogControl.open() 109 } 110 } 111 112 return ( 113 <> 114 <Button 115 label={ 116 isThreadAuthor ? _(msg`Edit who can reply`) : _(msg`Who can reply`) 117 } 118 onPress={onPressOpen} 119 {...(isThreadAuthor 120 ? Platform.select({ 121 web: { 122 onHoverIn: prefetch, 123 }, 124 native: { 125 onPressIn: prefetch, 126 }, 127 }) 128 : {})} 129 hitSlop={HITSLOP_10}> 130 {({hovered, focused, pressed}) => ( 131 <View 132 style={[ 133 a.flex_row, 134 a.align_center, 135 a.gap_xs, 136 (hovered || focused || pressed) && native({opacity: 0.5}), 137 style, 138 ]}> 139 <Icon 140 color={ 141 isThreadAuthor ? t.palette.primary_500 : t.palette.contrast_400 142 } 143 width={16} 144 settings={settings} 145 /> 146 <Text 147 style={[ 148 a.text_sm, 149 a.leading_tight, 150 isThreadAuthor 151 ? {color: t.palette.primary_500} 152 : t.atoms.text_contrast_medium, 153 (hovered || focused || pressed) && web(a.underline), 154 ]}> 155 {description} 156 </Text> 157 158 {isThreadAuthor && ( 159 <TinyChevronDownIcon width={8} fill={t.palette.primary_500} /> 160 )} 161 </View> 162 )} 163 </Button> 164 165 {isThreadAuthor ? ( 166 <PostInteractionSettingsDialog 167 postUri={post.uri} 168 rootPostUri={rootUri} 169 control={editDialogControl} 170 initialThreadgateView={post.threadgate} 171 /> 172 ) : ( 173 <WhoCanReplyDialog 174 control={infoDialogControl} 175 post={post} 176 settings={settings} 177 embeddingDisabled={Boolean(post.viewer?.embeddingDisabled)} 178 /> 179 )} 180 </> 181 ) 182} 183 184function Icon({ 185 color, 186 width, 187 settings, 188}: { 189 color: string 190 width?: number 191 settings: ThreadgateAllowUISetting[] 192}) { 193 const isEverybody = 194 settings.length === 0 || 195 settings.every(setting => setting.type === 'everybody') 196 const isNobody = !!settings.find(gate => gate.type === 'nobody') 197 const IconComponent = isEverybody 198 ? EarthIcon 199 : isNobody 200 ? CircleBanSignIcon 201 : GroupIcon 202 return <IconComponent fill={color} width={width} /> 203} 204 205function WhoCanReplyDialog({ 206 control, 207 post, 208 settings, 209 embeddingDisabled, 210}: { 211 control: Dialog.DialogControlProps 212 post: AppBskyFeedDefs.PostView 213 settings: ThreadgateAllowUISetting[] 214 embeddingDisabled: boolean 215}) { 216 const {_} = useLingui() 217 218 return ( 219 <Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}> 220 <Dialog.Handle /> 221 <Dialog.ScrollableInner 222 label={_(msg`Dialog: adjust who can interact with this post`)} 223 style={web({maxWidth: 400})}> 224 <View style={[a.gap_sm]}> 225 <Text style={[a.font_semi_bold, a.text_xl, a.pb_sm]}> 226 <Trans>Who can interact with this post?</Trans> 227 </Text> 228 <Rules 229 post={post} 230 settings={settings} 231 embeddingDisabled={embeddingDisabled} 232 /> 233 </View> 234 {IS_NATIVE && ( 235 <Button 236 label={_(msg`Close`)} 237 onPress={() => control.close()} 238 size="small" 239 variant="solid" 240 color="secondary" 241 style={[a.mt_5xl]}> 242 <ButtonText> 243 <Trans>Close</Trans> 244 </ButtonText> 245 </Button> 246 )} 247 <Dialog.Close /> 248 </Dialog.ScrollableInner> 249 </Dialog.Outer> 250 ) 251} 252 253function Rules({ 254 post, 255 settings, 256 embeddingDisabled, 257}: { 258 post: AppBskyFeedDefs.PostView 259 settings: ThreadgateAllowUISetting[] 260 embeddingDisabled: boolean 261}) { 262 const t = useTheme() 263 264 return ( 265 <> 266 <Text 267 style={[ 268 a.text_sm, 269 a.leading_snug, 270 a.flex_wrap, 271 t.atoms.text_contrast_medium, 272 ]}> 273 {settings.length === 0 ? ( 274 <Trans> 275 This post has an unknown type of threadgate on it. Your app may be 276 out of date. 277 </Trans> 278 ) : settings[0].type === 'everybody' ? ( 279 <Trans>Everybody can reply to this post.</Trans> 280 ) : settings[0].type === 'nobody' ? ( 281 <Trans>Replies to this post are disabled.</Trans> 282 ) : ( 283 <Trans> 284 Only{' '} 285 {settings.map((rule, i) => ( 286 <Fragment key={`rule-${i}`}> 287 <Rule rule={rule} post={post} lists={post.threadgate!.lists} /> 288 <Separator i={i} length={settings.length} /> 289 </Fragment> 290 ))}{' '} 291 can reply. 292 </Trans> 293 )}{' '} 294 </Text> 295 {embeddingDisabled && ( 296 <Text 297 style={[ 298 a.text_sm, 299 a.leading_snug, 300 a.flex_wrap, 301 t.atoms.text_contrast_medium, 302 ]}> 303 <Trans>No one but the author can quote this post.</Trans> 304 </Text> 305 )} 306 </> 307 ) 308} 309 310function Rule({ 311 rule, 312 post, 313 lists, 314}: { 315 rule: ThreadgateAllowUISetting 316 post: AppBskyFeedDefs.PostView 317 lists: AppBskyGraphDefs.ListViewBasic[] | undefined 318}) { 319 if (rule.type === 'mention') { 320 return <Trans>mentioned users</Trans> 321 } 322 if (rule.type === 'followers') { 323 return ( 324 <Trans> 325 users following{' '} 326 <InlineLinkText 327 label={`@${post.author.handle}`} 328 to={makeProfileLink(post.author)} 329 style={[a.text_sm, a.leading_snug]}> 330 @{post.author.handle} 331 </InlineLinkText> 332 </Trans> 333 ) 334 } 335 if (rule.type === 'following') { 336 return ( 337 <Trans> 338 users followed by{' '} 339 <InlineLinkText 340 label={`@${post.author.handle}`} 341 to={makeProfileLink(post.author)} 342 style={[a.text_sm, a.leading_snug]}> 343 @{post.author.handle} 344 </InlineLinkText> 345 </Trans> 346 ) 347 } 348 if (rule.type === 'list') { 349 const list = lists?.find(l => l.uri === rule.list) 350 if (list) { 351 const listUrip = new AtUri(list.uri) 352 return ( 353 <Trans> 354 <InlineLinkText 355 label={list.name} 356 to={makeListLink(listUrip.hostname, listUrip.rkey)} 357 style={[a.text_sm, a.leading_snug]}> 358 {list.name} 359 </InlineLinkText>{' '} 360 members 361 </Trans> 362 ) 363 } 364 } 365} 366 367function Separator({i, length}: {i: number; length: number}) { 368 if (length < 2 || i === length - 1) { 369 return null 370 } 371 if (i === length - 2) { 372 return ( 373 <> 374 {length > 2 ? ',' : ''} <Trans>and</Trans>{' '} 375 </> 376 ) 377 } 378 return <>, </> 379}