Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at cope-settings-sync 727 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 {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 * as Toast from '#/components/Toast' 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 { 244 type: 'error', 245 }, 246 ) 247 } finally { 248 setIsSaving(false) 249 } 250 }, [ 251 _, 252 ax, 253 props.postUri, 254 props.rootPostUri, 255 props.control, 256 editedPostgate, 257 editedAllowUISettings, 258 setIsSaving, 259 writePostgateRecord, 260 setThreadgateAllow, 261 isThreadgateOwnedByViewer, 262 ]) 263 264 return ( 265 <Dialog.ScrollableInner 266 label={_(msg`Edit post interaction settings`)} 267 style={[web({maxWidth: 400}), a.w_full]}> 268 {isLoading ? ( 269 <View 270 style={[ 271 a.flex_1, 272 a.py_5xl, 273 a.gap_md, 274 a.align_center, 275 a.justify_center, 276 ]}> 277 <Loader size="xl" /> 278 <Text style={[a.italic, a.text_center]}> 279 <Trans>Loading post interaction settings...</Trans> 280 </Text> 281 </View> 282 ) : ( 283 <> 284 <Header /> 285 <PostInteractionSettingsForm 286 replySettingsDisabled={!isThreadgateOwnedByViewer} 287 isSaving={isSaving} 288 onSave={onSave} 289 postgate={postgateValue} 290 onChangePostgate={setEditedPostgate} 291 threadgateAllowUISettings={allowUIValue} 292 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 293 /> 294 </> 295 )} 296 <Dialog.Close /> 297 </Dialog.ScrollableInner> 298 ) 299} 300 301export function PostInteractionSettingsForm({ 302 canSave = true, 303 onSave, 304 isSaving, 305 postgate, 306 onChangePostgate, 307 threadgateAllowUISettings, 308 onChangeThreadgateAllowUISettings, 309 replySettingsDisabled, 310 isDirty, 311 persist, 312 onChangePersist, 313}: PostInteractionSettingsFormProps) { 314 const t = useTheme() 315 const {_} = useLingui() 316 const playHaptic = useHaptics() 317 const [showLists, setShowLists] = useState(false) 318 const { 319 data: lists, 320 isPending: isListsPending, 321 isError: isListsError, 322 } = useMyListsQuery('curate') 323 const [quotesEnabled, setQuotesEnabled] = useState( 324 !( 325 postgate.embeddingRules && 326 postgate.embeddingRules.find( 327 v => v.$type === embeddingRules.disableRule.$type, 328 ) 329 ), 330 ) 331 332 const onChangeQuotesEnabled = useCallback( 333 (enabled: boolean) => { 334 setQuotesEnabled(enabled) 335 onChangePostgate( 336 createPostgateRecord({ 337 ...postgate, 338 embeddingRules: enabled ? [] : [embeddingRules.disableRule], 339 }), 340 ) 341 }, 342 [setQuotesEnabled, postgate, onChangePostgate], 343 ) 344 345 const noOneCanReply = !!threadgateAllowUISettings.find( 346 v => v.type === 'nobody', 347 ) 348 const everyoneCanReply = !!threadgateAllowUISettings.find( 349 v => v.type === 'everybody', 350 ) 351 const numberOfListsSelected = threadgateAllowUISettings.filter( 352 v => v.type === 'list', 353 ).length 354 355 const toggleGroupValues = (() => { 356 const values: string[] = [] 357 for (const setting of threadgateAllowUISettings) { 358 switch (setting.type) { 359 case 'everybody': 360 case 'nobody': 361 // no granularity, early return with nothing 362 return [] 363 case 'followers': 364 values.push('followers') 365 break 366 case 'following': 367 values.push('following') 368 break 369 case 'mention': 370 values.push('mention') 371 break 372 case 'list': 373 values.push(`list:${setting.list}`) 374 break 375 default: 376 break 377 } 378 } 379 return values 380 })() 381 382 const toggleGroupOnChange = (values: string[]) => { 383 const settings: ThreadgateAllowUISetting[] = [] 384 385 if (values.length === 0) { 386 settings.push({type: 'everybody'}) 387 } else { 388 for (const value of values) { 389 if (value.startsWith('list:')) { 390 const listId = value.slice('list:'.length) 391 settings.push({type: 'list', list: listId}) 392 } else { 393 settings.push({type: value as 'followers' | 'following' | 'mention'}) 394 } 395 } 396 } 397 398 onChangeThreadgateAllowUISettings(settings) 399 } 400 401 return ( 402 <View style={[a.flex_1, a.gap_lg]}> 403 <View style={[a.gap_lg]}> 404 {replySettingsDisabled && ( 405 <View 406 style={[ 407 a.px_md, 408 a.py_sm, 409 a.rounded_sm, 410 a.flex_row, 411 a.align_center, 412 a.gap_sm, 413 t.atoms.bg_contrast_25, 414 ]}> 415 <CircleInfo fill={t.atoms.text_contrast_low.color} /> 416 <Text 417 style={[a.flex_1, a.leading_snug, t.atoms.text_contrast_medium]}> 418 <Trans> 419 Reply settings are chosen by the author of the thread 420 </Trans> 421 </Text> 422 </View> 423 )} 424 425 <View style={[a.gap_sm, {opacity: replySettingsDisabled ? 0.3 : 1}]}> 426 <Text style={[a.text_md, a.font_medium]}> 427 <Trans>Who can reply</Trans> 428 </Text> 429 430 <Toggle.Group 431 label={_(msg`Set who can reply to your post`)} 432 type="radio" 433 maxSelections={1} 434 disabled={replySettingsDisabled} 435 values={ 436 everyoneCanReply ? ['everyone'] : noOneCanReply ? ['nobody'] : [] 437 } 438 onChange={val => { 439 if (val.includes('everyone')) { 440 onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 441 } else if (val.includes('nobody')) { 442 onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 443 } else { 444 onChangeThreadgateAllowUISettings([{type: 'mention'}]) 445 } 446 }}> 447 <View style={[a.flex_row, a.gap_sm]}> 448 <Toggle.Item 449 name="everyone" 450 type="checkbox" 451 label={_(msg`Allow anyone to reply`)} 452 style={[a.flex_1]}> 453 {({selected}) => ( 454 <Toggle.Panel active={selected}> 455 <Toggle.Radio /> 456 <Toggle.PanelText> 457 <Trans>Anyone</Trans> 458 </Toggle.PanelText> 459 </Toggle.Panel> 460 )} 461 </Toggle.Item> 462 <Toggle.Item 463 name="nobody" 464 type="checkbox" 465 label={_(msg`Disable replies entirely`)} 466 style={[a.flex_1]}> 467 {({selected}) => ( 468 <Toggle.Panel active={selected}> 469 <Toggle.Radio /> 470 <Toggle.PanelText> 471 <Trans>Nobody</Trans> 472 </Toggle.PanelText> 473 </Toggle.Panel> 474 )} 475 </Toggle.Item> 476 </View> 477 </Toggle.Group> 478 479 <Toggle.Group 480 label={_( 481 msg`Set precisely which groups of people can reply to your post`, 482 )} 483 values={toggleGroupValues} 484 onChange={toggleGroupOnChange} 485 disabled={replySettingsDisabled}> 486 <Toggle.PanelGroup> 487 <Toggle.Item 488 name="followers" 489 type="checkbox" 490 label={_(msg`Allow your followers to reply`)} 491 hitSlop={0}> 492 {({selected}) => ( 493 <Toggle.Panel active={selected} adjacent="trailing"> 494 <Toggle.Checkbox /> 495 <Toggle.PanelText> 496 <Trans>Your followers</Trans> 497 </Toggle.PanelText> 498 </Toggle.Panel> 499 )} 500 </Toggle.Item> 501 <Toggle.Item 502 name="following" 503 type="checkbox" 504 label={_(msg`Allow people you follow to reply`)} 505 hitSlop={0}> 506 {({selected}) => ( 507 <Toggle.Panel active={selected} adjacent="both"> 508 <Toggle.Checkbox /> 509 <Toggle.PanelText> 510 <Trans>People you follow</Trans> 511 </Toggle.PanelText> 512 </Toggle.Panel> 513 )} 514 </Toggle.Item> 515 <Toggle.Item 516 name="mention" 517 type="checkbox" 518 label={_(msg`Allow people you mention to reply`)} 519 hitSlop={0}> 520 {({selected}) => ( 521 <Toggle.Panel active={selected} adjacent="both"> 522 <Toggle.Checkbox /> 523 <Toggle.PanelText> 524 <Trans>People you mention</Trans> 525 </Toggle.PanelText> 526 </Toggle.Panel> 527 )} 528 </Toggle.Item> 529 530 <Button 531 label={ 532 showLists 533 ? _(msg`Hide lists`) 534 : _(msg`Show lists of users to select from`) 535 } 536 accessibilityRole="togglebutton" 537 hitSlop={0} 538 onPress={() => { 539 playHaptic('Light') 540 if (IS_IOS && !showLists) { 541 LayoutAnimation.configureNext({ 542 ...LayoutAnimation.Presets.linear, 543 duration: 175, 544 }) 545 } 546 setShowLists(s => !s) 547 }}> 548 <Toggle.Panel 549 active={numberOfListsSelected > 0} 550 adjacent={showLists ? 'both' : 'leading'}> 551 <Toggle.PanelText> 552 {numberOfListsSelected === 0 ? ( 553 <Trans>Select from your lists</Trans> 554 ) : ( 555 <Trans> 556 Select from your lists{' '} 557 <NestedText style={[a.font_normal, a.italic]}> 558 <Plural 559 value={numberOfListsSelected} 560 other="(# selected)" 561 /> 562 </NestedText> 563 </Trans> 564 )} 565 </Toggle.PanelText> 566 <Toggle.PanelIcon 567 icon={showLists ? ChevronUpIcon : ChevronDownIcon} 568 /> 569 </Toggle.Panel> 570 </Button> 571 {showLists && 572 (isListsPending ? ( 573 <Toggle.Panel> 574 <Toggle.PanelText> 575 <Trans>Loading lists...</Trans> 576 </Toggle.PanelText> 577 </Toggle.Panel> 578 ) : isListsError ? ( 579 <Toggle.Panel> 580 <Toggle.PanelText> 581 <Trans> 582 An error occurred while loading your lists :/ 583 </Trans> 584 </Toggle.PanelText> 585 </Toggle.Panel> 586 ) : lists.length === 0 ? ( 587 <Toggle.Panel> 588 <Toggle.PanelText> 589 <Trans>You don't have any lists yet.</Trans> 590 </Toggle.PanelText> 591 </Toggle.Panel> 592 ) : ( 593 lists.map((list, i) => ( 594 <Toggle.Item 595 key={list.uri} 596 name={`list:${list.uri}`} 597 type="checkbox" 598 label={_(msg`Allow users in ${list.name} to reply`)} 599 hitSlop={0}> 600 {({selected}) => ( 601 <Toggle.Panel 602 active={selected} 603 adjacent={ 604 i === lists.length - 1 ? 'leading' : 'both' 605 }> 606 <Toggle.Checkbox /> 607 <UserAvatar 608 size={24} 609 type="list" 610 avatar={list.avatar} 611 /> 612 <Toggle.PanelText>{list.name}</Toggle.PanelText> 613 </Toggle.Panel> 614 )} 615 </Toggle.Item> 616 )) 617 ))} 618 </Toggle.PanelGroup> 619 </Toggle.Group> 620 </View> 621 </View> 622 623 <Toggle.Item 624 name="quoteposts" 625 type="checkbox" 626 label={ 627 quotesEnabled 628 ? _(msg`Disable quote posts of this post`) 629 : _(msg`Enable quote posts of this post`) 630 } 631 value={quotesEnabled} 632 onChange={onChangeQuotesEnabled}> 633 {({selected}) => ( 634 <Toggle.Panel active={selected}> 635 <Toggle.PanelText icon={QuoteIcon}> 636 <Trans>Allow quote posts</Trans> 637 </Toggle.PanelText> 638 <Toggle.Switch /> 639 </Toggle.Panel> 640 )} 641 </Toggle.Item> 642 643 {typeof persist !== 'undefined' && ( 644 <View style={[{minHeight: 24}, a.justify_center]}> 645 {isDirty ? ( 646 <Toggle.Item 647 name="persist" 648 type="checkbox" 649 label={_(msg`Save these options for next time`)} 650 value={persist} 651 onChange={() => onChangePersist?.(!persist)}> 652 <Toggle.Checkbox /> 653 <Toggle.LabelText 654 style={[a.text_md, a.font_normal, t.atoms.text]}> 655 <Trans>Save these options for next time</Trans> 656 </Toggle.LabelText> 657 </Toggle.Item> 658 ) : ( 659 <Text style={[a.text_md, t.atoms.text_contrast_medium]}> 660 <Trans>These are your default settings</Trans> 661 </Text> 662 )} 663 </View> 664 )} 665 666 <Button 667 disabled={!canSave || isSaving} 668 label={_(msg`Save`)} 669 onPress={onSave} 670 color="primary" 671 size="large"> 672 <ButtonText> 673 <Trans>Save</Trans> 674 </ButtonText> 675 {isSaving && <ButtonIcon icon={Loader} />} 676 </Button> 677 </View> 678 ) 679} 680 681function Header() { 682 return ( 683 <View style={[a.pb_lg]}> 684 <Text style={[a.text_2xl, a.font_bold]}> 685 <Trans>Post interaction settings</Trans> 686 </Text> 687 </View> 688 ) 689} 690 691export function usePrefetchPostInteractionSettings({ 692 postUri, 693 rootPostUri, 694}: { 695 postUri: string 696 rootPostUri: string 697}) { 698 const ax = useAnalytics() 699 const queryClient = useQueryClient() 700 const agent = useAgent() 701 const getPost = useGetPost() 702 703 return useCallback(async () => { 704 try { 705 await Promise.all([ 706 queryClient.prefetchQuery({ 707 queryKey: createPostgateQueryKey(postUri), 708 queryFn: () => 709 getPostgateRecord({agent, postUri}).then(res => res ?? null), 710 staleTime: STALE.SECONDS.THIRTY, 711 }), 712 queryClient.prefetchQuery({ 713 queryKey: createThreadgateViewQueryKey(rootPostUri), 714 queryFn: async () => { 715 const post = await getPost({uri: rootPostUri}) 716 return post.threadgate ?? null 717 }, 718 staleTime: STALE.SECONDS.THIRTY, 719 }), 720 ]) 721 } catch (e: any) { 722 ax.logger.error(`Failed to prefetch post interaction settings`, { 723 safeMessage: e.message, 724 }) 725 } 726 }, [ax, queryClient, agent, postUri, rootPostUri, getPost]) 727}