mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at samuel/fancy-queue 536 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 260 onChangeThreadgateAllowUISettings(newSelected) 261 } 262 263 const onChangeQuotesEnabled = React.useCallback( 264 (enabled: boolean) => { 265 setQuotesEnabled(enabled) 266 onChangePostgate( 267 createPostgateRecord({ 268 ...postgate, 269 embeddingRules: enabled ? [] : [embeddingRules.disableRule], 270 }), 271 ) 272 }, 273 [setQuotesEnabled, postgate, onChangePostgate], 274 ) 275 276 const noOneCanReply = !!threadgateAllowUISettings.find( 277 v => v.type === 'nobody', 278 ) 279 280 return ( 281 <View> 282 <View style={[a.flex_1, a.gap_md]}> 283 <Text style={[a.text_2xl, a.font_bold]}> 284 <Trans>Post interaction settings</Trans> 285 </Text> 286 287 <View style={[a.gap_lg]}> 288 <Text style={[a.text_md]}> 289 <Trans>Customize who can interact with this post.</Trans> 290 </Text> 291 292 <Divider /> 293 294 <View style={[a.gap_sm]}> 295 <Text style={[a.font_bold, a.text_lg]}> 296 <Trans>Quote settings</Trans> 297 </Text> 298 299 <Toggle.Item 300 name="quoteposts" 301 type="checkbox" 302 label={ 303 quotesEnabled 304 ? _(msg`Click to disable quote posts of this post.`) 305 : _(msg`Click to enable quote posts of this post.`) 306 } 307 value={quotesEnabled} 308 onChange={onChangeQuotesEnabled} 309 style={[, a.justify_between, a.pt_xs]}> 310 <Text style={[t.atoms.text_contrast_medium]}> 311 {quotesEnabled ? ( 312 <Trans>Quote posts enabled</Trans> 313 ) : ( 314 <Trans>Quote posts disabled</Trans> 315 )} 316 </Text> 317 <Toggle.Switch /> 318 </Toggle.Item> 319 </View> 320 321 <Divider /> 322 323 {replySettingsDisabled && ( 324 <View 325 style={[ 326 a.px_md, 327 a.py_sm, 328 a.rounded_sm, 329 a.flex_row, 330 a.align_center, 331 a.gap_sm, 332 t.atoms.bg_contrast_25, 333 ]}> 334 <CircleInfo fill={t.atoms.text_contrast_low.color} /> 335 <Text 336 style={[ 337 a.flex_1, 338 a.leading_snug, 339 t.atoms.text_contrast_medium, 340 ]}> 341 <Trans> 342 Reply settings are chosen by the author of the thread 343 </Trans> 344 </Text> 345 </View> 346 )} 347 348 <View 349 style={[ 350 a.gap_sm, 351 { 352 opacity: replySettingsDisabled ? 0.3 : 1, 353 }, 354 ]}> 355 <Text style={[a.font_bold, a.text_lg]}> 356 <Trans>Reply settings</Trans> 357 </Text> 358 359 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 360 <Trans>Allow replies from:</Trans> 361 </Text> 362 363 <View style={[a.flex_row, a.gap_sm]}> 364 <Selectable 365 label={_(msg`Everybody`)} 366 isSelected={ 367 !!threadgateAllowUISettings.find(v => v.type === 'everybody') 368 } 369 onPress={() => 370 onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 371 } 372 style={{flex: 1}} 373 disabled={replySettingsDisabled} 374 /> 375 <Selectable 376 label={_(msg`Nobody`)} 377 isSelected={noOneCanReply} 378 onPress={() => 379 onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 380 } 381 style={{flex: 1}} 382 disabled={replySettingsDisabled} 383 /> 384 </View> 385 386 {!noOneCanReply && ( 387 <> 388 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 389 <Trans>Or combine these options:</Trans> 390 </Text> 391 392 <View style={[a.gap_sm]}> 393 <Selectable 394 label={_(msg`Mentioned users`)} 395 isSelected={ 396 !!threadgateAllowUISettings.find( 397 v => v.type === 'mention', 398 ) 399 } 400 onPress={() => onPressAudience({type: 'mention'})} 401 disabled={replySettingsDisabled} 402 /> 403 <Selectable 404 label={_(msg`Followed users`)} 405 isSelected={ 406 !!threadgateAllowUISettings.find( 407 v => v.type === 'following', 408 ) 409 } 410 onPress={() => onPressAudience({type: 'following'})} 411 disabled={replySettingsDisabled} 412 /> 413 {lists && lists.length > 0 414 ? lists.map(list => ( 415 <Selectable 416 key={list.uri} 417 label={_(msg`Users in "${list.name}"`)} 418 isSelected={ 419 !!threadgateAllowUISettings.find( 420 v => v.type === 'list' && v.list === list.uri, 421 ) 422 } 423 onPress={() => 424 onPressAudience({type: 'list', list: list.uri}) 425 } 426 disabled={replySettingsDisabled} 427 /> 428 )) 429 : // No loading states to avoid jumps for the common case (no lists) 430 null} 431 </View> 432 </> 433 )} 434 </View> 435 </View> 436 </View> 437 438 <Button 439 label={_(msg`Save`)} 440 onPress={onSave} 441 color="primary" 442 size="large" 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_bold]}>{label}</Text> 495 {isSelected ? ( 496 <Check size="sm" fill={t.palette.primary_500} /> 497 ) : ( 498 <View /> 499 )} 500 </View> 501 )} 502 </Button> 503 ) 504} 505 506export function usePrefetchPostInteractionSettings({ 507 postUri, 508 rootPostUri, 509}: { 510 postUri: string 511 rootPostUri: string 512}) { 513 const queryClient = useQueryClient() 514 const agent = useAgent() 515 516 return React.useCallback(async () => { 517 try { 518 await Promise.all([ 519 queryClient.prefetchQuery({ 520 queryKey: createPostgateQueryKey(postUri), 521 queryFn: () => getPostgateRecord({agent, postUri}), 522 staleTime: STALE.SECONDS.THIRTY, 523 }), 524 queryClient.prefetchQuery({ 525 queryKey: createThreadgateViewQueryKey(rootPostUri), 526 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 527 staleTime: STALE.SECONDS.THIRTY, 528 }), 529 ]) 530 } catch (e: any) { 531 logger.error(`Failed to prefetch post interaction settings`, { 532 safeMessage: e.message, 533 }) 534 } 535 }, [queryClient, agent, postUri, rootPostUri]) 536}