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