mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at tooltip 17 kB view raw
1import React from 'react' 2import {type StyleProp, View, type ViewStyle} from 'react-native' 3import { 4 type AppBskyFeedDefs, 5 type AppBskyFeedPostgate, 6 AtUri, 7} from '@atproto/api' 8import {msg, Trans} from '@lingui/macro' 9import {useLingui} from '@lingui/react' 10import {useQueryClient} from '@tanstack/react-query' 11import isEqual from 'lodash.isequal' 12 13import {logger} from '#/logger' 14import {STALE} from '#/state/queries' 15import {useMyListsQuery} from '#/state/queries/my-lists' 16import { 17 createPostgateQueryKey, 18 getPostgateRecord, 19 usePostgateQuery, 20 useWritePostgateMutation, 21} from '#/state/queries/postgate' 22import { 23 createPostgateRecord, 24 embeddingRules, 25} from '#/state/queries/postgate/util' 26import { 27 createThreadgateViewQueryKey, 28 getThreadgateView, 29 type ThreadgateAllowUISetting, 30 threadgateViewToAllowUISetting, 31 useSetThreadgateAllowMutation, 32 useThreadgateViewQuery, 33} from '#/state/queries/threadgate' 34import {useAgent, useSession} from '#/state/session' 35import * as Toast from '#/view/com/util/Toast' 36import {atoms as a, useTheme} from '#/alf' 37import {Button, ButtonIcon, ButtonText} from '#/components/Button' 38import * as Dialog from '#/components/Dialog' 39import {Divider} from '#/components/Divider' 40import * as Toggle from '#/components/forms/Toggle' 41import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 42import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo' 43import {Loader} from '#/components/Loader' 44import {Text} from '#/components/Typography' 45 46export type PostInteractionSettingsFormProps = { 47 canSave?: boolean 48 onSave: () => void 49 isSaving?: boolean 50 51 postgate: AppBskyFeedPostgate.Record 52 onChangePostgate: (v: AppBskyFeedPostgate.Record) => void 53 54 threadgateAllowUISettings: ThreadgateAllowUISetting[] 55 onChangeThreadgateAllowUISettings: (v: ThreadgateAllowUISetting[]) => void 56 57 replySettingsDisabled?: boolean 58} 59 60export function PostInteractionSettingsControlledDialog({ 61 control, 62 ...rest 63}: PostInteractionSettingsFormProps & { 64 control: Dialog.DialogControlProps 65}) { 66 const t = useTheme() 67 const {_} = useLingui() 68 69 return ( 70 <Dialog.Outer control={control}> 71 <Dialog.Handle /> 72 <Dialog.ScrollableInner 73 label={_(msg`Edit post interaction settings`)} 74 style={[{maxWidth: 500}, a.w_full]}> 75 <View style={[a.gap_md]}> 76 <Header /> 77 <PostInteractionSettingsForm {...rest} /> 78 <Text 79 style={[ 80 a.pt_sm, 81 a.text_sm, 82 a.leading_snug, 83 t.atoms.text_contrast_medium, 84 ]}> 85 <Trans> 86 You can set default interaction settings in{' '} 87 <Text style={[a.font_bold, t.atoms.text_contrast_medium]}> 88 Settings &rarr; Moderation &rarr; Interaction settings 89 </Text> 90 . 91 </Trans> 92 </Text> 93 </View> 94 <Dialog.Close /> 95 </Dialog.ScrollableInner> 96 </Dialog.Outer> 97 ) 98} 99 100export function Header() { 101 return ( 102 <View style={[a.gap_md, a.pb_sm]}> 103 <Text style={[a.text_2xl, a.font_bold]}> 104 <Trans>Post interaction settings</Trans> 105 </Text> 106 <Text style={[a.text_md, a.pb_xs]}> 107 <Trans>Customize who can interact with this post.</Trans> 108 </Text> 109 <Divider /> 110 </View> 111 ) 112} 113 114export type PostInteractionSettingsDialogProps = { 115 control: Dialog.DialogControlProps 116 /** 117 * URI of the post to edit the interaction settings for. Could be a root post 118 * or could be a reply. 119 */ 120 postUri: string 121 /** 122 * The URI of the root post in the thread. Used to determine if the viewer 123 * owns the threadgate record and can therefore edit it. 124 */ 125 rootPostUri: string 126 /** 127 * Optional initial {@link AppBskyFeedDefs.ThreadgateView} to use if we 128 * happen to have one before opening the settings dialog. 129 */ 130 initialThreadgateView?: AppBskyFeedDefs.ThreadgateView 131} 132 133export function PostInteractionSettingsDialog( 134 props: PostInteractionSettingsDialogProps, 135) { 136 return ( 137 <Dialog.Outer control={props.control}> 138 <Dialog.Handle /> 139 <PostInteractionSettingsDialogControlledInner {...props} /> 140 </Dialog.Outer> 141 ) 142} 143 144export function PostInteractionSettingsDialogControlledInner( 145 props: PostInteractionSettingsDialogProps, 146) { 147 const {_} = useLingui() 148 const {currentAccount} = useSession() 149 const [isSaving, setIsSaving] = React.useState(false) 150 151 const {data: threadgateViewLoaded, isLoading: isLoadingThreadgate} = 152 useThreadgateViewQuery({postUri: props.rootPostUri}) 153 const {data: postgate, isLoading: isLoadingPostgate} = usePostgateQuery({ 154 postUri: props.postUri, 155 }) 156 157 const {mutateAsync: writePostgateRecord} = useWritePostgateMutation() 158 const {mutateAsync: setThreadgateAllow} = useSetThreadgateAllowMutation() 159 160 const [editedPostgate, setEditedPostgate] = 161 React.useState<AppBskyFeedPostgate.Record>() 162 const [editedAllowUISettings, setEditedAllowUISettings] = 163 React.useState<ThreadgateAllowUISetting[]>() 164 165 const isLoading = isLoadingThreadgate || isLoadingPostgate 166 const threadgateView = threadgateViewLoaded || props.initialThreadgateView 167 const isThreadgateOwnedByViewer = React.useMemo(() => { 168 return currentAccount?.did === new AtUri(props.rootPostUri).host 169 }, [props.rootPostUri, currentAccount?.did]) 170 171 const postgateValue = React.useMemo(() => { 172 return ( 173 editedPostgate || postgate || createPostgateRecord({post: props.postUri}) 174 ) 175 }, [postgate, editedPostgate, props.postUri]) 176 const allowUIValue = React.useMemo(() => { 177 return ( 178 editedAllowUISettings || threadgateViewToAllowUISetting(threadgateView) 179 ) 180 }, [threadgateView, editedAllowUISettings]) 181 182 const onSave = React.useCallback(async () => { 183 if (!editedPostgate && !editedAllowUISettings) { 184 props.control.close() 185 return 186 } 187 188 setIsSaving(true) 189 190 try { 191 const requests = [] 192 193 if (editedPostgate) { 194 requests.push( 195 writePostgateRecord({ 196 postUri: props.postUri, 197 postgate: editedPostgate, 198 }), 199 ) 200 } 201 202 if (editedAllowUISettings && isThreadgateOwnedByViewer) { 203 requests.push( 204 setThreadgateAllow({ 205 postUri: props.rootPostUri, 206 allow: editedAllowUISettings, 207 }), 208 ) 209 } 210 211 await Promise.all(requests) 212 213 props.control.close() 214 } catch (e: any) { 215 logger.error(`Failed to save post interaction settings`, { 216 source: 'PostInteractionSettingsDialogControlledInner', 217 safeMessage: e.message, 218 }) 219 Toast.show( 220 _( 221 msg`There was an issue. Please check your internet connection and try again.`, 222 ), 223 'xmark', 224 ) 225 } finally { 226 setIsSaving(false) 227 } 228 }, [ 229 _, 230 props.postUri, 231 props.rootPostUri, 232 props.control, 233 editedPostgate, 234 editedAllowUISettings, 235 setIsSaving, 236 writePostgateRecord, 237 setThreadgateAllow, 238 isThreadgateOwnedByViewer, 239 ]) 240 241 return ( 242 <Dialog.ScrollableInner 243 label={_(msg`Edit post interaction settings`)} 244 style={[{maxWidth: 500}, a.w_full]}> 245 <View style={[a.gap_md]}> 246 <Header /> 247 248 {isLoading ? ( 249 <View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}> 250 <Loader size="xl" /> 251 </View> 252 ) : ( 253 <PostInteractionSettingsForm 254 replySettingsDisabled={!isThreadgateOwnedByViewer} 255 isSaving={isSaving} 256 onSave={onSave} 257 postgate={postgateValue} 258 onChangePostgate={setEditedPostgate} 259 threadgateAllowUISettings={allowUIValue} 260 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 261 /> 262 )} 263 </View> 264 </Dialog.ScrollableInner> 265 ) 266} 267 268export function PostInteractionSettingsForm({ 269 canSave = true, 270 onSave, 271 isSaving, 272 postgate, 273 onChangePostgate, 274 threadgateAllowUISettings, 275 onChangeThreadgateAllowUISettings, 276 replySettingsDisabled, 277}: PostInteractionSettingsFormProps) { 278 const t = useTheme() 279 const {_} = useLingui() 280 const {data: lists} = useMyListsQuery('curate') 281 const [quotesEnabled, setQuotesEnabled] = React.useState( 282 !( 283 postgate.embeddingRules && 284 postgate.embeddingRules.find( 285 v => v.$type === embeddingRules.disableRule.$type, 286 ) 287 ), 288 ) 289 290 const onPressAudience = (setting: ThreadgateAllowUISetting) => { 291 // remove boolean values 292 let newSelected: ThreadgateAllowUISetting[] = 293 threadgateAllowUISettings.filter( 294 v => v.type !== 'nobody' && v.type !== 'everybody', 295 ) 296 // toggle 297 const i = newSelected.findIndex(v => isEqual(v, setting)) 298 if (i === -1) { 299 newSelected.push(setting) 300 } else { 301 newSelected.splice(i, 1) 302 } 303 if (newSelected.length === 0) { 304 newSelected.push({type: 'everybody'}) 305 } 306 307 onChangeThreadgateAllowUISettings(newSelected) 308 } 309 310 const onChangeQuotesEnabled = React.useCallback( 311 (enabled: boolean) => { 312 setQuotesEnabled(enabled) 313 onChangePostgate( 314 createPostgateRecord({ 315 ...postgate, 316 embeddingRules: enabled ? [] : [embeddingRules.disableRule], 317 }), 318 ) 319 }, 320 [setQuotesEnabled, postgate, onChangePostgate], 321 ) 322 323 const noOneCanReply = !!threadgateAllowUISettings.find( 324 v => v.type === 'nobody', 325 ) 326 327 return ( 328 <View> 329 <View style={[a.flex_1, a.gap_md]}> 330 <View style={[a.gap_lg]}> 331 <View style={[a.gap_sm]}> 332 <Text style={[a.font_bold, a.text_lg]}> 333 <Trans>Quote settings</Trans> 334 </Text> 335 336 <Toggle.Item 337 name="quoteposts" 338 type="checkbox" 339 label={ 340 quotesEnabled 341 ? _(msg`Click to disable quote posts of this post.`) 342 : _(msg`Click to enable quote posts of this post.`) 343 } 344 value={quotesEnabled} 345 onChange={onChangeQuotesEnabled} 346 style={[a.justify_between, a.pt_xs]}> 347 <Text style={[t.atoms.text_contrast_medium]}> 348 <Trans>Allow quote posts</Trans> 349 </Text> 350 <Toggle.Switch /> 351 </Toggle.Item> 352 </View> 353 354 <Divider /> 355 356 {replySettingsDisabled && ( 357 <View 358 style={[ 359 a.px_md, 360 a.py_sm, 361 a.rounded_sm, 362 a.flex_row, 363 a.align_center, 364 a.gap_sm, 365 t.atoms.bg_contrast_25, 366 ]}> 367 <CircleInfo fill={t.atoms.text_contrast_low.color} /> 368 <Text 369 style={[ 370 a.flex_1, 371 a.leading_snug, 372 t.atoms.text_contrast_medium, 373 ]}> 374 <Trans> 375 Reply settings are chosen by the author of the thread 376 </Trans> 377 </Text> 378 </View> 379 )} 380 381 <View 382 style={[ 383 a.gap_sm, 384 { 385 opacity: replySettingsDisabled ? 0.3 : 1, 386 }, 387 ]}> 388 <Text style={[a.font_bold, a.text_lg]}> 389 <Trans>Reply settings</Trans> 390 </Text> 391 392 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 393 <Trans>Allow replies from:</Trans> 394 </Text> 395 396 <View style={[a.flex_row, a.gap_sm]}> 397 <Selectable 398 label={_(msg`Everybody`)} 399 isSelected={ 400 !!threadgateAllowUISettings.find(v => v.type === 'everybody') 401 } 402 onPress={() => 403 onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 404 } 405 style={{flex: 1}} 406 disabled={replySettingsDisabled} 407 /> 408 <Selectable 409 label={_(msg`Nobody`)} 410 isSelected={noOneCanReply} 411 onPress={() => 412 onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 413 } 414 style={{flex: 1}} 415 disabled={replySettingsDisabled} 416 /> 417 </View> 418 419 {!noOneCanReply && ( 420 <> 421 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 422 <Trans>Or combine these options:</Trans> 423 </Text> 424 425 <View style={[a.gap_sm]}> 426 <Selectable 427 label={_(msg`Mentioned users`)} 428 isSelected={ 429 !!threadgateAllowUISettings.find( 430 v => v.type === 'mention', 431 ) 432 } 433 onPress={() => onPressAudience({type: 'mention'})} 434 disabled={replySettingsDisabled} 435 /> 436 <Selectable 437 label={_(msg`Users you follow`)} 438 isSelected={ 439 !!threadgateAllowUISettings.find( 440 v => v.type === 'following', 441 ) 442 } 443 onPress={() => onPressAudience({type: 'following'})} 444 disabled={replySettingsDisabled} 445 /> 446 <Selectable 447 label={_(msg`Your followers`)} 448 isSelected={ 449 !!threadgateAllowUISettings.find( 450 v => v.type === 'followers', 451 ) 452 } 453 onPress={() => onPressAudience({type: 'followers'})} 454 disabled={replySettingsDisabled} 455 /> 456 {lists && lists.length > 0 457 ? lists.map(list => ( 458 <Selectable 459 key={list.uri} 460 label={_(msg`Users in "${list.name}"`)} 461 isSelected={ 462 !!threadgateAllowUISettings.find( 463 v => v.type === 'list' && v.list === list.uri, 464 ) 465 } 466 onPress={() => 467 onPressAudience({type: 'list', list: list.uri}) 468 } 469 disabled={replySettingsDisabled} 470 /> 471 )) 472 : // No loading states to avoid jumps for the common case (no lists) 473 null} 474 </View> 475 </> 476 )} 477 </View> 478 </View> 479 </View> 480 481 <Button 482 disabled={!canSave || isSaving} 483 label={_(msg`Save`)} 484 onPress={onSave} 485 color="primary" 486 size="large" 487 variant="solid" 488 style={a.mt_xl}> 489 <ButtonText>{_(msg`Save`)}</ButtonText> 490 {isSaving && <ButtonIcon icon={Loader} position="right" />} 491 </Button> 492 </View> 493 ) 494} 495 496function Selectable({ 497 label, 498 isSelected, 499 onPress, 500 style, 501 disabled, 502}: { 503 label: string 504 isSelected: boolean 505 onPress: () => void 506 style?: StyleProp<ViewStyle> 507 disabled?: boolean 508}) { 509 const t = useTheme() 510 return ( 511 <Button 512 disabled={disabled} 513 onPress={onPress} 514 label={label} 515 accessibilityRole="checkbox" 516 aria-checked={isSelected} 517 accessibilityState={{ 518 checked: isSelected, 519 }} 520 style={a.flex_1}> 521 {({hovered, focused}) => ( 522 <View 523 style={[ 524 a.flex_1, 525 a.flex_row, 526 a.align_center, 527 a.justify_between, 528 a.rounded_sm, 529 a.p_md, 530 {minHeight: 40}, // for consistency with checkmark icon visible or not 531 t.atoms.bg_contrast_50, 532 (hovered || focused) && t.atoms.bg_contrast_100, 533 isSelected && { 534 backgroundColor: t.palette.primary_100, 535 }, 536 style, 537 ]}> 538 <Text style={[a.text_sm, isSelected && a.font_bold]}>{label}</Text> 539 {isSelected ? ( 540 <Check size="sm" fill={t.palette.primary_500} /> 541 ) : ( 542 <View /> 543 )} 544 </View> 545 )} 546 </Button> 547 ) 548} 549 550export function usePrefetchPostInteractionSettings({ 551 postUri, 552 rootPostUri, 553}: { 554 postUri: string 555 rootPostUri: string 556}) { 557 const queryClient = useQueryClient() 558 const agent = useAgent() 559 560 return React.useCallback(async () => { 561 try { 562 await Promise.all([ 563 queryClient.prefetchQuery({ 564 queryKey: createPostgateQueryKey(postUri), 565 queryFn: () => 566 getPostgateRecord({agent, postUri}).then(res => res ?? null), 567 staleTime: STALE.SECONDS.THIRTY, 568 }), 569 queryClient.prefetchQuery({ 570 queryKey: createThreadgateViewQueryKey(rootPostUri), 571 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 572 staleTime: STALE.SECONDS.THIRTY, 573 }), 574 ]) 575 } catch (e: any) { 576 logger.error(`Failed to prefetch post interaction settings`, { 577 safeMessage: e.message, 578 }) 579 } 580 }, [queryClient, agent, postUri, rootPostUri]) 581}