mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at rm-broken-strings 539 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 {quotesEnabled ? ( 315 <Trans>Quote posts enabled</Trans> 316 ) : ( 317 <Trans>Quote posts disabled</Trans> 318 )} 319 </Text> 320 <Toggle.Switch /> 321 </Toggle.Item> 322 </View> 323 324 <Divider /> 325 326 {replySettingsDisabled && ( 327 <View 328 style={[ 329 a.px_md, 330 a.py_sm, 331 a.rounded_sm, 332 a.flex_row, 333 a.align_center, 334 a.gap_sm, 335 t.atoms.bg_contrast_25, 336 ]}> 337 <CircleInfo fill={t.atoms.text_contrast_low.color} /> 338 <Text 339 style={[ 340 a.flex_1, 341 a.leading_snug, 342 t.atoms.text_contrast_medium, 343 ]}> 344 <Trans> 345 Reply settings are chosen by the author of the thread 346 </Trans> 347 </Text> 348 </View> 349 )} 350 351 <View 352 style={[ 353 a.gap_sm, 354 { 355 opacity: replySettingsDisabled ? 0.3 : 1, 356 }, 357 ]}> 358 <Text style={[a.font_bold, a.text_lg]}> 359 <Trans>Reply settings</Trans> 360 </Text> 361 362 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 363 <Trans>Allow replies from:</Trans> 364 </Text> 365 366 <View style={[a.flex_row, a.gap_sm]}> 367 <Selectable 368 label={_(msg`Everybody`)} 369 isSelected={ 370 !!threadgateAllowUISettings.find(v => v.type === 'everybody') 371 } 372 onPress={() => 373 onChangeThreadgateAllowUISettings([{type: 'everybody'}]) 374 } 375 style={{flex: 1}} 376 disabled={replySettingsDisabled} 377 /> 378 <Selectable 379 label={_(msg`Nobody`)} 380 isSelected={noOneCanReply} 381 onPress={() => 382 onChangeThreadgateAllowUISettings([{type: 'nobody'}]) 383 } 384 style={{flex: 1}} 385 disabled={replySettingsDisabled} 386 /> 387 </View> 388 389 {!noOneCanReply && ( 390 <> 391 <Text style={[a.pt_sm, t.atoms.text_contrast_medium]}> 392 <Trans>Or combine these options:</Trans> 393 </Text> 394 395 <View style={[a.gap_sm]}> 396 <Selectable 397 label={_(msg`Mentioned users`)} 398 isSelected={ 399 !!threadgateAllowUISettings.find( 400 v => v.type === 'mention', 401 ) 402 } 403 onPress={() => onPressAudience({type: 'mention'})} 404 disabled={replySettingsDisabled} 405 /> 406 <Selectable 407 label={_(msg`Followed users`)} 408 isSelected={ 409 !!threadgateAllowUISettings.find( 410 v => v.type === 'following', 411 ) 412 } 413 onPress={() => onPressAudience({type: 'following'})} 414 disabled={replySettingsDisabled} 415 /> 416 {lists && lists.length > 0 417 ? lists.map(list => ( 418 <Selectable 419 key={list.uri} 420 label={_(msg`Users in "${list.name}"`)} 421 isSelected={ 422 !!threadgateAllowUISettings.find( 423 v => v.type === 'list' && v.list === list.uri, 424 ) 425 } 426 onPress={() => 427 onPressAudience({type: 'list', list: list.uri}) 428 } 429 disabled={replySettingsDisabled} 430 /> 431 )) 432 : // No loading states to avoid jumps for the common case (no lists) 433 null} 434 </View> 435 </> 436 )} 437 </View> 438 </View> 439 </View> 440 441 <Button 442 label={_(msg`Save`)} 443 onPress={onSave} 444 color="primary" 445 size="large" 446 variant="solid" 447 style={a.mt_xl}> 448 <ButtonText>{_(msg`Save`)}</ButtonText> 449 {isSaving && <ButtonIcon icon={Loader} position="right" />} 450 </Button> 451 </View> 452 ) 453} 454 455function Selectable({ 456 label, 457 isSelected, 458 onPress, 459 style, 460 disabled, 461}: { 462 label: string 463 isSelected: boolean 464 onPress: () => void 465 style?: StyleProp<ViewStyle> 466 disabled?: boolean 467}) { 468 const t = useTheme() 469 return ( 470 <Button 471 disabled={disabled} 472 onPress={onPress} 473 label={label} 474 accessibilityRole="checkbox" 475 aria-checked={isSelected} 476 accessibilityState={{ 477 checked: isSelected, 478 }} 479 style={a.flex_1}> 480 {({hovered, focused}) => ( 481 <View 482 style={[ 483 a.flex_1, 484 a.flex_row, 485 a.align_center, 486 a.justify_between, 487 a.rounded_sm, 488 a.p_md, 489 {minHeight: 40}, // for consistency with checkmark icon visible or not 490 t.atoms.bg_contrast_50, 491 (hovered || focused) && t.atoms.bg_contrast_100, 492 isSelected && { 493 backgroundColor: t.palette.primary_100, 494 }, 495 style, 496 ]}> 497 <Text style={[a.text_sm, isSelected && a.font_bold]}>{label}</Text> 498 {isSelected ? ( 499 <Check size="sm" fill={t.palette.primary_500} /> 500 ) : ( 501 <View /> 502 )} 503 </View> 504 )} 505 </Button> 506 ) 507} 508 509export function usePrefetchPostInteractionSettings({ 510 postUri, 511 rootPostUri, 512}: { 513 postUri: string 514 rootPostUri: string 515}) { 516 const queryClient = useQueryClient() 517 const agent = useAgent() 518 519 return React.useCallback(async () => { 520 try { 521 await Promise.all([ 522 queryClient.prefetchQuery({ 523 queryKey: createPostgateQueryKey(postUri), 524 queryFn: () => getPostgateRecord({agent, postUri}), 525 staleTime: STALE.SECONDS.THIRTY, 526 }), 527 queryClient.prefetchQuery({ 528 queryKey: createThreadgateViewQueryKey(rootPostUri), 529 queryFn: () => getThreadgateView({agent, postUri: rootPostUri}), 530 staleTime: STALE.SECONDS.THIRTY, 531 }), 532 ]) 533 } catch (e: any) { 534 logger.error(`Failed to prefetch post interaction settings`, { 535 safeMessage: e.message, 536 }) 537 } 538 }, [queryClient, agent, postUri, rootPostUri]) 539}