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