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