mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at ruby-v 535 lines 16 kB view raw
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 <View style={[a.flex_1, a.py_4xl, a.align_center, a.justify_center]}> 208 <Loader size="xl" /> 209 </View> 210 ) : ( 211 <PostInteractionSettingsForm 212 replySettingsDisabled={!isThreadgateOwnedByViewer} 213 isSaving={isSaving} 214 onSave={onSave} 215 postgate={postgateValue} 216 onChangePostgate={setEditedPostgate} 217 threadgateAllowUISettings={allowUIValue} 218 onChangeThreadgateAllowUISettings={setEditedAllowUISettings} 219 /> 220 )} 221 </Dialog.ScrollableInner> 222 ) 223} 224 225export function PostInteractionSettingsForm({ 226 onSave, 227 isSaving, 228 postgate, 229 onChangePostgate, 230 threadgateAllowUISettings, 231 onChangeThreadgateAllowUISettings, 232 replySettingsDisabled, 233}: PostInteractionSettingsFormProps) { 234 const t = useTheme() 235 const {_} = useLingui() 236 const {data: lists} = useMyListsQuery('curate') 237 const [quotesEnabled, setQuotesEnabled] = React.useState( 238 !( 239 postgate.embeddingRules && 240 postgate.embeddingRules.find( 241 v => v.$type === embeddingRules.disableRule.$type, 242 ) 243 ), 244 ) 245 246 const onPressAudience = (setting: ThreadgateAllowUISetting) => { 247 // remove boolean values 248 let newSelected: ThreadgateAllowUISetting[] = 249 threadgateAllowUISettings.filter( 250 v => v.type !== 'nobody' && v.type !== 'everybody', 251 ) 252 // toggle 253 const i = newSelected.findIndex(v => isEqual(v, setting)) 254 if (i === -1) { 255 newSelected.push(setting) 256 } else { 257 newSelected.splice(i, 1) 258 } 259 if (newSelected.length === 0) { 260 newSelected.push({type: 'everybody'}) 261 } 262 263 onChangeThreadgateAllowUISettings(newSelected) 264 } 265 266 const onChangeQuotesEnabled = React.useCallback( 267 (enabled: boolean) => { 268 setQuotesEnabled(enabled) 269 onChangePostgate( 270 createPostgateRecord({ 271 ...postgate, 272 embeddingRules: enabled ? [] : [embeddingRules.disableRule], 273 }), 274 ) 275 }, 276 [setQuotesEnabled, postgate, onChangePostgate], 277 ) 278 279 const noOneCanReply = !!threadgateAllowUISettings.find( 280 v => v.type === 'nobody', 281 ) 282 283 return ( 284 <View> 285 <View style={[a.flex_1, a.gap_md]}> 286 <Text style={[a.text_2xl, a.font_bold]}> 287 <Trans>Post interaction settings</Trans> 288 </Text> 289 290 <View style={[a.gap_lg]}> 291 <Text style={[a.text_md]}> 292 <Trans>Customize who can interact with this post.</Trans> 293 </Text> 294 295 <Divider /> 296 297 <View style={[a.gap_sm]}> 298 <Text style={[a.font_bold, a.text_lg]}> 299 <Trans>Quote settings</Trans> 300 </Text> 301 302 <Toggle.Item 303 name="quoteposts" 304 type="checkbox" 305 label={ 306 quotesEnabled 307 ? _(msg`Click to disable quote posts of this post.`) 308 : _(msg`Click to enable quote posts of this post.`) 309 } 310 value={quotesEnabled} 311 onChange={onChangeQuotesEnabled} 312 style={[a.justify_between, a.pt_xs]}> 313 <Text style={[t.atoms.text_contrast_medium]}> 314 <Trans>Quote posts enabled</Trans> 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 color="primary" 441 size="large" 442 variant="solid" 443 style={a.mt_xl}> 444 <ButtonText>{_(msg`Save`)}</ButtonText> 445 {isSaving && <ButtonIcon icon={Loader} position="right" />} 446 </Button> 447 </View> 448 ) 449} 450 451function Selectable({ 452 label, 453 isSelected, 454 onPress, 455 style, 456 disabled, 457}: { 458 label: string 459 isSelected: boolean 460 onPress: () => void 461 style?: StyleProp<ViewStyle> 462 disabled?: boolean 463}) { 464 const t = useTheme() 465 return ( 466 <Button 467 disabled={disabled} 468 onPress={onPress} 469 label={label} 470 accessibilityRole="checkbox" 471 aria-checked={isSelected} 472 accessibilityState={{ 473 checked: isSelected, 474 }} 475 style={a.flex_1}> 476 {({hovered, focused}) => ( 477 <View 478 style={[ 479 a.flex_1, 480 a.flex_row, 481 a.align_center, 482 a.justify_between, 483 a.rounded_sm, 484 a.p_md, 485 {minHeight: 40}, // for consistency with checkmark icon visible or not 486 t.atoms.bg_contrast_50, 487 (hovered || focused) && t.atoms.bg_contrast_100, 488 isSelected && { 489 backgroundColor: t.palette.primary_100, 490 }, 491 style, 492 ]}> 493 <Text style={[a.text_sm, isSelected && a.font_bold]}>{label}</Text> 494 {isSelected ? ( 495 <Check size="sm" fill={t.palette.primary_500} /> 496 ) : ( 497 <View /> 498 )} 499 </View> 500 )} 501 </Button> 502 ) 503} 504 505export function usePrefetchPostInteractionSettings({ 506 postUri, 507 rootPostUri, 508}: { 509 postUri: string 510 rootPostUri: string 511}) { 512 const queryClient = useQueryClient() 513 const agent = useAgent() 514 515 return React.useCallback(async () => { 516 try { 517 await Promise.all([ 518 queryClient.prefetchQuery({ 519 queryKey: createPostgateQueryKey(postUri), 520 queryFn: () => getPostgateRecord({agent, postUri}), 521 staleTime: STALE.SECONDS.THIRTY, 522 }), 523 queryClient.prefetchQuery({ 524 queryKey: createThreadgateViewQueryKey(rootPostUri), 525 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 526 staleTime: STALE.SECONDS.THIRTY, 527 }), 528 ]) 529 } catch (e: any) { 530 logger.error(`Failed to prefetch post interaction settings`, { 531 safeMessage: e.message, 532 }) 533 } 534 }, [queryClient, agent, postUri, rootPostUri]) 535}