Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
fork

Configure Feed

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

at a876aae44ea07494ebea9727350aa060b81f317b 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}