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