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