Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 725 lines 23 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {LayoutAnimation, Text as NestedText, View} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPostgate, 6 AtUri, 7} from '@atproto/api' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10import {Plural, Trans} from '@lingui/react/macro' 11import {useQueryClient} from '@tanstack/react-query' 12 13import {useHaptics} from '#/lib/haptics' 14import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 15import {STALE} from '#/state/queries' 16import {useMyListsQuery} from '#/state/queries/my-lists' 17import {useGetPost} from '#/state/queries/post' 18import { 19 createPostgateQueryKey, 20 getPostgateRecord, 21 usePostgateQuery, 22 useWritePostgateMutation, 23} from '#/state/queries/postgate' 24import { 25 createPostgateRecord, 26 embeddingRules, 27} from '#/state/queries/postgate/util' 28import { 29 createThreadgateViewQueryKey, 30 type ThreadgateAllowUISetting, 31 threadgateViewToAllowUISetting, 32 useSetThreadgateAllowMutation, 33 useThreadgateViewQuery, 34} from '#/state/queries/threadgate' 35import { 36 PostThreadContextProvider, 37 usePostThreadContext, 38} from '#/state/queries/usePostThread' 39import {useAgent, useSession} from '#/state/session' 40import * as Toast from '#/view/com/util/Toast' 41import {UserAvatar} from '#/view/com/util/UserAvatar' 42import {atoms as a, useTheme, web} from '#/alf' 43import {Button, ButtonIcon, ButtonText} from '#/components/Button' 44import * as Dialog from '#/components/Dialog' 45import * as Toggle from '#/components/forms/Toggle' 46import { 47 ChevronBottom_Stroke2_Corner0_Rounded as ChevronDownIcon, 48 ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon, 49} from '#/components/icons/Chevron' 50import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 51import {CloseQuote_Stroke2_Corner1_Rounded as QuoteIcon} from '#/components/icons/Quote' 52import {Loader} from '#/components/Loader' 53import {Text} from '#/components/Typography' 54import {useAnalytics} from '#/analytics' 55import {IS_IOS} from '#/env' 56 57export type PostInteractionSettingsFormProps = { 58 canSave?: boolean 59 onSave: () => void 60 isSaving?: boolean 61 62 isDirty?: boolean 63 persist?: boolean 64 onChangePersist?: (v: boolean) => void 65 66 postgate: AppBskyFeedPostgate.Record 67 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 68 69 threadgateAllowUISettings: ThreadgateAllowUISetting[] 70 onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void 71 72 replySettingsDisabled?: boolean 73} 74 75/** 76 * Threadgate settings dialog. Used in the composer. 77 */ 78export function PostInteractionSettingsControlledDialog({ 79 control, 80 ...rest 81}: PostInteractionSettingsFormProps & { 82 control: Dialog.DialogControlProps 83}) { 84 const ax = useAnalytics() 85 const onClose = useNonReactiveCallback(() => { 86 ax.metric('composer:threadgate:save', { 87 hasChanged: !!rest.isDirty, 88 persist: !!rest.persist, 89 replyOptions: 90 rest.threadgateAllowUISettings?.map(gate => gate.type)?.join(',') ?? '', 91 quotesEnabled: !rest.postgate?.embeddingRules?.find( 92 v => v.$type === embeddingRules.disableRule.$type, 93 ), 94 }) 95 }) 96 97 return ( 98 <Dialog.Outer 99 control={control} 100 nativeOptions={{ 101 preventExpansion: true, 102 preventDismiss: rest.isDirty && rest.persist, 103 }} 104 onClose={onClose}> 105 <Dialog.Handle /> 106 <DialogInner {...rest} /> 107 </Dialog.Outer> 108 ) 109} 110 111function DialogInner(props: Omit<PostInteractionSettingsFormProps, 'control'>) { 112 const {_} = useLingui() 113 114 return ( 115 <Dialog.ScrollableInner 116 label={_(msg`Edit post interaction settings`)} 117 style={[web({maxWidth: 400}), a.w_full]}> 118 <Header /> 119 <PostInteractionSettingsForm {...props} /> 120 <Dialog.Close /> 121 </Dialog.ScrollableInner> 122 ) 123} 124 125export type PostInteractionSettingsDialogProps = { 126 control: Dialog.DialogControlProps 127 /** 128 * URI of the post to edit the interaction settings for. Could be a root post 129 * or could be a reply. 130 */ 131 postUri: string 132 /** 133 * The URI of the root post in the thread. Used to determine if the viewer 134 * owns the threadgate record and can therefore edit it. 135 */ 136 rootPostUri: string 137 /** 138 * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we 139 * happen to have one before opening the settings dialog. 140 */ 141 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 142} 143 144/** 145 * Threadgate settings dialog. Used in the thread. 146 */ 147export function PostInteractionSettingsDialog( 148 props: PostInteractionSettingsDialogProps, 149) { 150 const postThreadContext = usePostThreadContext() 151 return ( 152 <Dialog.Outer 153 control={props.control} 154 nativeOptions={{preventExpansion: true}}> 155 <Dialog.Handle /> 156 <PostThreadContextProvider context={postThreadContext}> 157 <PostInteractionSettingsDialogControlledInner {...props} /> 158 </PostThreadContextProvider> 159 </Dialog.Outer> 160 ) 161} 162 163export function PostInteractionSettingsDialogControlledInner( 164 props: PostInteractionSettingsDialogProps, 165) { 166 const ax = useAnalytics() 167 const {_} = useLingui() 168 const {currentAccount} = useSession() 169 const [isSaving, setIsSaving] = useState(false) 170 171 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 172 useThreadgateViewQuery({postUri: props.rootPostUri}) 173 const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({ 174 postUri: props.postUri, 175 }) 176 177 const {mutateAsync: writePostgateRecord} = useWritePostgateMutation() 178 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 179 180 const [editedPostgate, setEditedPostgate] = 181 useState<AppBskyFeedPostgate.Record>() 182 const [editedAllowUISettings, setEditedAllowUISettings] = 183 useState<ThreadgateAllowUISetting[]>() 184 185 const isLoading = isLoadingThreadgate || isLoadingPostgate 186 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 187 const isThreadgateOwnedByViewer = useMemo(() => { 188 return currentAccount?.did === new AtUri(props.rootPostUri).host 189 }, [props.rootPostUri, currentAccount?.did]) 190 191 const postgateValue = useMemo(() => { 192 return ( 193 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 194 ) 195 }, [postgate, editedPostgate, props.postUri]) 196 const allowUIValue = useMemo(() => { 197 return ( 198 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 199 ) 200 }, [threadgateView, editedAllowUISettings]) 201 202 const onSave = useCallback(async () => { 203 if (!editedPostgate && !editedAllowUISettings) { 204 props.control.close() 205 return 206 } 207 208 setIsSaving(true) 209 210 try { 211 const requests = [] 212 213 if (editedPostgate) { 214 requests.push( 215 writePostgateRecord({ 216 postUri: props.postUri, 217 postgate: editedPostgate, 218 }), 219 ) 220 } 221 222 if (editedAllowUISettings && isThreadgateOwnedByViewer) { 223 requests.push( 224 setThreadgateAllow({ 225 postUri: props.rootPostUri, 226 allow: editedAllowUISettings, 227 }), 228 ) 229 } 230 231 await Promise.all(requests) 232 233 props.control.close() 234 } catch (e: any) { 235 ax.logger.error(`Failed to save post interaction settings`, { 236 source: 'PostInteractionSettingsDialogControlledInner', 237 safeMessage: e.message, 238 }) 239 Toast.show( 240 _( 241 msg`There was an issue. Please check your internet connection and try again.`, 242 ), 243 'xmark', 244 ) 245 } finally { 246 setIsSaving(false) 247 } 248 }, [ 249 _, 250 ax, 251 props.postUri, 252 props.rootPostUri, 253 props.control, 254 editedPostgate, 255 editedAllowUISettings, 256 setIsSaving, 257 writePostgateRecord, 258 setThreadgateAllow, 259 isThreadgateOwnedByViewer, 260 ]) 261 262 return ( 263 <Dialog.ScrollableInner 264 label={_(msg`Edit post interaction settings`)} 265 style={[web({maxWidth: 400}), a.w_full]}> 266 {isLoading ? ( 267 <View 268 style={[ 269 a.flex_1, 270 a.py_5xl, 271 a.gap_md, 272 a.align_center, 273 a.justify_center, 274 ]}> 275 <Loader size="xl" /> 276 <Text style={[a.italic, a.text_center]}> 277 <Trans>Loading post interaction settings...</Trans> 278 </Text> 279 </View> 280 ) : ( 281 <> 282 <Header /> 283 <PostInteractionSettingsForm 284 replySettingsDisabled={!isThreadgateOwnedByViewer} 285 isSaving={isSaving} 286 onSave={onSave} 287 postgate={postgateValue} 288 onChangePostgate={setEditedPostgate} 289 threadgateAllowUISettings={allowUIValue} 290 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 291 /> 292 </> 293 )} 294 <Dialog.Close /> 295 </Dialog.ScrollableInner> 296 ) 297} 298 299export function PostInteractionSettingsForm({ 300 canSave = true, 301 onSave, 302 isSaving, 303 postgate, 304 onChangePostgate, 305 threadgateAllowUISettings, 306 onChangeThreadgateAllowUISettings, 307 replySettingsDisabled, 308 isDirty, 309 persist, 310 onChangePersist, 311}: PostInteractionSettingsFormProps) { 312 const t = useTheme() 313 const {_} = useLingui() 314 const playHaptic = useHaptics() 315 const [showLists, setShowLists] = useState(false) 316 const { 317 data: lists, 318 isPending: isListsPending, 319 isError: isListsError, 320 } = useMyListsQuery('curate') 321 const [quotesEnabled, setQuotesEnabled] = useState( 322 !( 323 postgate.embeddingRules && 324 postgate.embeddingRules.find( 325 v => v.$type === embeddingRules.disableRule.$type, 326 ) 327 ), 328 ) 329 330 const onChangeQuotesEnabled = useCallback( 331 (enabled: boolean) => { 332 setQuotesEnabled(enabled) 333 onChangePostgate( 334 createPostgateRecord({ 335 ...postgate, 336 embeddingRules: enabled ? [] : [embeddingRules.disableRule], 337 }), 338 ) 339 }, 340 [setQuotesEnabled, postgate, onChangePostgate], 341 ) 342 343 const noOneCanReply = !!threadgateAllowUISettings.find( 344 v => v.type === 'nobody', 345 ) 346 const everyoneCanReply = !!threadgateAllowUISettings.find( 347 v => v.type === 'everybody', 348 ) 349 const numberOfListsSelected = threadgateAllowUISettings.filter( 350 v => v.type === 'list', 351 ).length 352 353 const toggleGroupValues = useMemo(() => { 354 const values: string[] = [] 355 for (const setting of threadgateAllowUISettings) { 356 switch (setting.type) { 357 case 'everybody': 358 case 'nobody': 359 // no granularity, early return with nothing 360 return [] 361 case 'followers': 362 values.push('followers') 363 break 364 case 'following': 365 values.push('following') 366 break 367 case 'mention': 368 values.push('mention') 369 break 370 case 'list': 371 values.push(`list:${setting.list}`) 372 break 373 default: 374 break 375 } 376 } 377 return values 378 }, [threadgateAllowUISettings]) 379 380 const toggleGroupOnChange = (values: string[]) => { 381 const settings: ThreadgateAllowUISetting[] = [] 382 383 if (values.length === 0) { 384 settings.push({type: 'everybody'}) 385 } else { 386 for (const value of values) { 387 if (value.startsWith('list:')) { 388 const listId = value.slice('list:'.length) 389 settings.push({type: 'list', list: listId}) 390 } else { 391 settings.push({type: value as 'followers' | 'following' | 'mention'}) 392 } 393 } 394 } 395 396 onChangeThreadgateAllowUISettings(settings) 397 } 398 399 return ( 400 <View style={[a.flex_1, a.gap_lg]}> 401 <View style={[a.gap_lg]}> 402 {replySettingsDisabled && ( 403 <View 404 style={[ 405 a.px_md, 406 a.py_sm, 407 a.rounded_sm, 408 a.flex_row, 409 a.align_center, 410 a.gap_sm, 411 t.atoms.bg_contrast_25, 412 ]}> 413 <CircleInfo fill={t.atoms.text_contrast_low.color} /> 414 <Text 415 style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}> 416 <Trans> 417 Reply settings are chosen by the author of the thread 418 </Trans> 419 </Text> 420 </View> 421 )} 422 423 <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}> 424 <Text style={[a.text_md, a.font_medium]}> 425 <Trans>Who can reply</Trans> 426 </Text> 427 428 <Toggle.Group 429 label={_(msg`Set who can reply to your post`)} 430 type="radio" 431 maxSelections={1} 432 disabled={replySettingsDisabled} 433 values={ 434 everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : [] 435 } 436 onChange={val => { 437 if (val.includes('everyone')) { 438 onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 439 } else if (val.includes('nobody')) { 440 onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 441 } else { 442 onChangeThreadgateAllowUISettings([{type: 'mention'}]) 443 } 444 }}> 445 <View style={[a.flex_row, a.gap_sm]}> 446 <Toggle.Item 447 name="everyone" 448 type="checkbox" 449 label={_(msg`Allow anyone to reply`)} 450 style={[a.flex_1]}> 451 {({selected}) => ( 452 <Toggle.Panel active={selected}> 453 <Toggle.Radio /> 454 <Toggle.PanelText> 455 <Trans>Anyone</Trans> 456 </Toggle.PanelText> 457 </Toggle.Panel> 458 )} 459 </Toggle.Item> 460 <Toggle.Item 461 name="nobody" 462 type="checkbox" 463 label={_(msg`Disable replies entirely`)} 464 style={[a.flex_1]}> 465 {({selected}) => ( 466 <Toggle.Panel active={selected}> 467 <Toggle.Radio /> 468 <Toggle.PanelText> 469 <Trans>Nobody</Trans> 470 </Toggle.PanelText> 471 </Toggle.Panel> 472 )} 473 </Toggle.Item> 474 </View> 475 </Toggle.Group> 476 477 <Toggle.Group 478 label={_( 479 msg`Set precisely which groups of people can reply to your post`, 480 )} 481 values={toggleGroupValues} 482 onChange={toggleGroupOnChange} 483 disabled={replySettingsDisabled}> 484 <Toggle.PanelGroup> 485 <Toggle.Item 486 name="followers" 487 type="checkbox" 488 label={_(msg`Allow your followers to reply`)} 489 hitSlop={0}> 490 {({selected}) => ( 491 <Toggle.Panel active={selected} adjacent="trailing"> 492 <Toggle.Checkbox /> 493 <Toggle.PanelText> 494 <Trans>Your followers</Trans> 495 </Toggle.PanelText> 496 </Toggle.Panel> 497 )} 498 </Toggle.Item> 499 <Toggle.Item 500 name="following" 501 type="checkbox" 502 label={_(msg`Allow people you follow to reply`)} 503 hitSlop={0}> 504 {({selected}) => ( 505 <Toggle.Panel active={selected} adjacent="both"> 506 <Toggle.Checkbox /> 507 <Toggle.PanelText> 508 <Trans>People you follow</Trans> 509 </Toggle.PanelText> 510 </Toggle.Panel> 511 )} 512 </Toggle.Item> 513 <Toggle.Item 514 name="mention" 515 type="checkbox" 516 label={_(msg`Allow people you mention to reply`)} 517 hitSlop={0}> 518 {({selected}) => ( 519 <Toggle.Panel active={selected} adjacent="both"> 520 <Toggle.Checkbox /> 521 <Toggle.PanelText> 522 <Trans>People you mention</Trans> 523 </Toggle.PanelText> 524 </Toggle.Panel> 525 )} 526 </Toggle.Item> 527 528 <Button 529 label={ 530 showLists 531 ? _(msg`Hide lists`) 532 : _(msg`Show lists of users to select from`) 533 } 534 accessibilityRole="togglebutton" 535 hitSlop={0} 536 onPress={() => { 537 playHaptic('Light') 538 if (IS_IOS && !showLists) { 539 LayoutAnimation.configureNext({ 540 ...LayoutAnimation.Presets.linear, 541 duration: 175, 542 }) 543 } 544 setShowLists(s => !s) 545 }}> 546 <Toggle.Panel 547 active={numberOfListsSelected > 0} 548 adjacent={showLists ? 'both' : 'leading'}> 549 <Toggle.PanelText> 550 {numberOfListsSelected === 0 ? ( 551 <Trans>Select from your lists</Trans> 552 ) : ( 553 <Trans> 554 Select from your lists{' '} 555 <NestedText style={[a.font_normal, a.italic]}> 556 <Plural 557 value={numberOfListsSelected} 558 other="(# selected)" 559 /> 560 </NestedText> 561 </Trans> 562 )} 563 </Toggle.PanelText> 564 <Toggle.PanelIcon 565 icon={showLists ? ChevronUpIcon : ChevronDownIcon} 566 /> 567 </Toggle.Panel> 568 </Button> 569 {showLists && 570 (isListsPending ? ( 571 <Toggle.Panel> 572 <Toggle.PanelText> 573 <Trans>Loading lists...</Trans> 574 </Toggle.PanelText> 575 </Toggle.Panel> 576 ) : isListsError ? ( 577 <Toggle.Panel> 578 <Toggle.PanelText> 579 <Trans> 580 An error occurred while loading your lists :/ 581 </Trans> 582 </Toggle.PanelText> 583 </Toggle.Panel> 584 ) : lists.length === 0 ? ( 585 <Toggle.Panel> 586 <Toggle.PanelText> 587 <Trans>You don't have any lists yet.</Trans> 588 </Toggle.PanelText> 589 </Toggle.Panel> 590 ) : ( 591 lists.map((list, i) => ( 592 <Toggle.Item 593 key={list.uri} 594 name={`list:${list.uri}`} 595 type="checkbox" 596 label={_(msg`Allow users in ${list.name} to reply`)} 597 hitSlop={0}> 598 {({selected}) => ( 599 <Toggle.Panel 600 active={selected} 601 adjacent={ 602 i === lists.length - 1 ? 'leading' : 'both' 603 }> 604 <Toggle.Checkbox /> 605 <UserAvatar 606 size={24} 607 type="list" 608 avatar={list.avatar} 609 /> 610 <Toggle.PanelText>{list.name}</Toggle.PanelText> 611 </Toggle.Panel> 612 )} 613 </Toggle.Item> 614 )) 615 ))} 616 </Toggle.PanelGroup> 617 </Toggle.Group> 618 </View> 619 </View> 620 621 <Toggle.Item 622 name="quoteposts" 623 type="checkbox" 624 label={ 625 quotesEnabled 626 ? _(msg`Disable quote posts of this post`) 627 : _(msg`Enable quote posts of this post`) 628 } 629 value={quotesEnabled} 630 onChange={onChangeQuotesEnabled}> 631 {({selected}) => ( 632 <Toggle.Panel active={selected}> 633 <Toggle.PanelText icon={QuoteIcon}> 634 <Trans>Allow quote posts</Trans> 635 </Toggle.PanelText> 636 <Toggle.Switch /> 637 </Toggle.Panel> 638 )} 639 </Toggle.Item> 640 641 {typeof persist !== 'undefined' && ( 642 <View style={[{minHeight: 24}, a.justify_center]}> 643 {isDirty ? ( 644 <Toggle.Item 645 name="persist" 646 type="checkbox" 647 label={_(msg`Save these options for next time`)} 648 value={persist} 649 onChange={() => onChangePersist?.(!persist)}> 650 <Toggle.Checkbox /> 651 <Toggle.LabelText 652 style={[a.text_md, a.font_normal, t.atoms.text]}> 653 <Trans>Save these options for next time</Trans> 654 </Toggle.LabelText> 655 </Toggle.Item> 656 ) : ( 657 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 658 <Trans>These are your default settings</Trans> 659 </Text> 660 )} 661 </View> 662 )} 663 664 <Button 665 disabled={!canSave || isSaving} 666 label={_(msg`Save`)} 667 onPress={onSave} 668 color="primary" 669 size="large"> 670 <ButtonText> 671 <Trans>Save</Trans> 672 </ButtonText> 673 {isSaving && <ButtonIcon icon={Loader} />} 674 </Button> 675 </View> 676 ) 677} 678 679function Header() { 680 return ( 681 <View style={[a.pb_lg]}> 682 <Text style={[a.text_2xl, a.font_bold]}> 683 <Trans>Post interaction settings</Trans> 684 </Text> 685 </View> 686 ) 687} 688 689export function usePrefetchPostInteractionSettings({ 690 postUri, 691 rootPostUri, 692}: { 693 postUri: string 694 rootPostUri: string 695}) { 696 const ax = useAnalytics() 697 const queryClient = useQueryClient() 698 const agent = useAgent() 699 const getPost = useGetPost() 700 701 return useCallback(async () => { 702 try { 703 await Promise.all([ 704 queryClient.prefetchQuery({ 705 queryKey: createPostgateQueryKey(postUri), 706 queryFn: () => 707 getPostgateRecord({agent, postUri}).then(res => res ?? null), 708 staleTime: STALE.SECONDS.THIRTY, 709 }), 710 queryClient.prefetchQuery({ 711 queryKey: createThreadgateViewQueryKey(rootPostUri), 712 queryFn: async () => { 713 const post = await getPost({uri: rootPostUri}) 714 return post.threadgate ?? null 715 }, 716 staleTime: STALE.SECONDS.THIRTY, 717 }), 718 ]) 719 } catch (e: any) { 720 ax.logger.error(`Failed to prefetch post interaction settings`, { 721 safeMessage: e.message, 722 }) 723 } 724 }, [ax, queryClient, agent, postUri, rootPostUri, getPost]) 725}