Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 555 lines 18 kB view raw
1import {Fragment, useCallback} from 'react' 2import {Linking, View} from 'react-native' 3import {LABELS} from '@atproto/api' 4import {msg} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7import {useFocusEffect} from '@react-navigation/native' 8 9import {getLabelingServiceTitle, isAppLabeler} from '#/lib/moderation' 10import { 11 type CommonNavigatorParams, 12 type NativeStackScreenProps, 13} from '#/lib/routes/types' 14import {logger} from '#/logger' 15import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' 16import {useRemoveLabelersMutation} from '#/state/queries/labeler' 17import { 18 useMyLabelersQuery, 19 usePreferencesQuery, 20 type UsePreferencesQueryResponse, 21 usePreferencesSetAdultContentMutation, 22} from '#/state/queries/preferences' 23import {isNonConfigurableModerationAuthority} from '#/state/session/additional-moderation-authorities' 24import {useSetMinimalShellMode} from '#/state/shell' 25import {atoms as a, useBreakpoints, useTheme, type ViewStyleProp} from '#/alf' 26import * as Admonition from '#/components/Admonition' 27import {AgeAssuranceAdmonition} from '#/components/ageAssurance/AgeAssuranceAdmonition' 28import {useAgeAssuranceCopy} from '#/components/ageAssurance/useAgeAssuranceCopy' 29import {Button, ButtonIcon, ButtonText} from '#/components/Button' 30import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 31import {Divider} from '#/components/Divider' 32import * as Toggle from '#/components/forms/Toggle' 33import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 34import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign' 35import {CircleCheck_Stroke2_Corner0_Rounded as CircleCheck} from '#/components/icons/CircleCheck' 36import {type Props as SVGIconProps} from '#/components/icons/common' 37import {EditBig_Stroke2_Corner0_Rounded as EditBig} from '#/components/icons/EditBig' 38import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter' 39import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group' 40import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person' 41import * as LabelingService from '#/components/LabelingServiceCard' 42import * as Layout from '#/components/Layout' 43import {InlineLinkText, Link} from '#/components/Link' 44import {ListMaybePlaceholder} from '#/components/Lists' 45import {Loader} from '#/components/Loader' 46import {GlobalLabelPreference} from '#/components/moderation/LabelPreference' 47import * as Toast from '#/components/Toast' 48import {Text} from '#/components/Typography' 49import {useAgeAssurance} from '#/ageAssurance' 50import {IS_IOS} from '#/env' 51 52function ErrorState({error}: {error: string}) { 53 const t = useTheme() 54 return ( 55 <View style={[a.p_xl]}> 56 <Text 57 style={[ 58 a.text_md, 59 a.leading_normal, 60 a.pb_md, 61 t.atoms.text_contrast_medium, 62 ]}> 63 <Trans> 64 Hmmmm, it seems we're having trouble loading this data. See below for 65 more details. If this issue persists, please contact us. 66 </Trans> 67 </Text> 68 <View 69 style={[ 70 a.relative, 71 a.py_md, 72 a.px_lg, 73 a.rounded_md, 74 a.mb_2xl, 75 t.atoms.bg_contrast_25, 76 ]}> 77 <Text style={[a.text_md, a.leading_normal]}>{error}</Text> 78 </View> 79 </View> 80 ) 81} 82 83export function ModerationScreen( 84 _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>, 85) { 86 const {_} = useLingui() 87 const { 88 isLoading: isPreferencesLoading, 89 error: preferencesError, 90 data: preferences, 91 } = usePreferencesQuery() 92 93 const isLoading = isPreferencesLoading 94 const error = preferencesError 95 96 return ( 97 <Layout.Screen testID="moderationScreen"> 98 <Layout.Header.Outer> 99 <Layout.Header.BackButton /> 100 <Layout.Header.Content> 101 <Layout.Header.TitleText> 102 <Trans>Moderation</Trans> 103 </Layout.Header.TitleText> 104 </Layout.Header.Content> 105 <Layout.Header.Slot /> 106 </Layout.Header.Outer> 107 <Layout.Content> 108 {isLoading ? ( 109 <ListMaybePlaceholder isLoading={true} sideBorders={false} /> 110 ) : error || !preferences ? ( 111 <ErrorState 112 error={ 113 preferencesError?.toString() || 114 _(msg`Something went wrong, please try again.`) 115 } 116 /> 117 ) : ( 118 <ModerationScreenInner preferences={preferences} /> 119 )} 120 </Layout.Content> 121 </Layout.Screen> 122 ) 123} 124 125function SubItem({ 126 title, 127 icon: Icon, 128 style, 129}: ViewStyleProp & { 130 title: string 131 icon: React.ComponentType<SVGIconProps> 132}) { 133 const t = useTheme() 134 return ( 135 <View 136 style={[ 137 a.w_full, 138 a.flex_row, 139 a.align_center, 140 a.justify_between, 141 a.p_lg, 142 a.gap_sm, 143 style, 144 ]}> 145 <View style={[a.flex_row, a.align_center, a.gap_md]}> 146 <Icon size="md" style={[t.atoms.text_contrast_medium]} /> 147 <Text style={[a.text_sm, a.font_semi_bold]}>{title}</Text> 148 </View> 149 <ChevronRight 150 size="sm" 151 style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]} 152 /> 153 </View> 154 ) 155} 156 157export function ModerationScreenInner({ 158 preferences, 159}: { 160 preferences: UsePreferencesQueryResponse 161}) { 162 const {_} = useLingui() 163 const t = useTheme() 164 const setMinimalShellMode = useSetMinimalShellMode() 165 const {gtMobile} = useBreakpoints() 166 const {mutedWordsDialogControl} = useGlobalDialogsControlContext() 167 const { 168 isLoading: isLabelersLoading, 169 data: labelers, 170 error: labelersError, 171 } = useMyLabelersQuery() 172 const {mutateAsync: removeLabelers, isPending: isRemovingLabelers} = 173 useRemoveLabelersMutation() 174 const aa = useAgeAssurance() 175 const isBirthdateUpdateAllowed = useIsBirthdateUpdateAllowed() 176 const aaCopy = useAgeAssuranceCopy() 177 178 const subscribedDids = preferences.moderationPrefs.labelers.map(l => l.did) 179 const returnedDids = new Set(labelers?.map(l => l.creator.did)) 180 const unavailableDids = subscribedDids.filter( 181 did => 182 !returnedDids.has(did) && 183 !isAppLabeler(did) && 184 !isNonConfigurableModerationAuthority(did), 185 ) 186 187 const handleCleanup = async () => { 188 try { 189 await removeLabelers({dids: unavailableDids}) 190 Toast.show(_(msg`Removed unavailable services`), { 191 type: 'success', 192 }) 193 } catch (e: any) { 194 logger.error('Failed to remove unavailable labelers', { 195 safeMessage: e.message, 196 }) 197 } 198 } 199 200 useFocusEffect( 201 useCallback(() => { 202 setMinimalShellMode(false) 203 }, [setMinimalShellMode]), 204 ) 205 206 const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} = 207 usePreferencesSetAdultContentMutation() 208 let adultContentEnabled = !!( 209 (optimisticAdultContent && optimisticAdultContent.enabled) || 210 (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled) 211 ) 212 const adultContentUIDisabledOnIOS = IS_IOS && !adultContentEnabled 213 let adultContentUIDisabled = adultContentUIDisabledOnIOS 214 215 if (aa.flags.adultContentDisabled) { 216 adultContentEnabled = false 217 adultContentUIDisabled = true 218 } 219 220 const onToggleAdultContentEnabled = useCallback( 221 async (selected: boolean) => { 222 try { 223 await setAdultContentPref({ 224 enabled: selected, 225 }) 226 } catch (e: any) { 227 logger.error(`Failed to set adult content pref`, { 228 message: e.message, 229 }) 230 } 231 }, 232 [setAdultContentPref], 233 ) 234 235 return ( 236 <View style={[a.pt_2xl, a.px_lg, gtMobile && a.px_2xl]}> 237 {aa.flags.adultContentDisabled && isBirthdateUpdateAllowed && ( 238 <View style={[a.pb_2xl]}> 239 <Admonition.Admonition type="tip" style={[a.pb_md]}> 240 <Trans> 241 Your declared age is under 18. Some settings below may be 242 disabled. If this was a mistake, you may edit your birthdate in 243 your{' '} 244 <InlineLinkText 245 to="/settings/account" 246 label={_(msg`Go to account settings`)}> 247 account settings 248 </InlineLinkText> 249 . 250 </Trans> 251 </Admonition.Admonition> 252 </View> 253 )} 254 255 <Text 256 style={[ 257 a.text_md, 258 a.font_semi_bold, 259 a.pb_md, 260 t.atoms.text_contrast_high, 261 ]}> 262 <Trans>Moderation tools</Trans> 263 </Text> 264 265 <View 266 style={[ 267 a.w_full, 268 a.rounded_md, 269 a.overflow_hidden, 270 t.atoms.bg_contrast_25, 271 ]}> 272 <Link 273 label={_(msg`View your default post interaction settings`)} 274 testID="interactionSettingsBtn" 275 to="/moderation/interaction-settings"> 276 {state => ( 277 <SubItem 278 title={_(msg`Interaction settings`)} 279 icon={EditBig} 280 style={[ 281 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 282 ]} 283 /> 284 )} 285 </Link> 286 <Divider /> 287 <Button 288 testID="mutedWordsBtn" 289 label={_(msg`Open muted words and tags settings`)} 290 onPress={() => mutedWordsDialogControl.open()}> 291 {state => ( 292 <SubItem 293 title={_(msg`Muted words & tags`)} 294 icon={Filter} 295 style={[ 296 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 297 ]} 298 /> 299 )} 300 </Button> 301 <Divider /> 302 <Link 303 label={_(msg`View your moderation lists`)} 304 testID="moderationlistsBtn" 305 to="/moderation/modlists"> 306 {state => ( 307 <SubItem 308 title={_(msg`Moderation lists`)} 309 icon={Group} 310 style={[ 311 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 312 ]} 313 /> 314 )} 315 </Link> 316 <Divider /> 317 <Link 318 label={_(msg`View your muted accounts`)} 319 testID="mutedAccountsBtn" 320 to="/moderation/muted-accounts"> 321 {state => ( 322 <SubItem 323 title={_(msg`Muted accounts`)} 324 icon={Person} 325 style={[ 326 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 327 ]} 328 /> 329 )} 330 </Link> 331 <Divider /> 332 <Link 333 label={_(msg`View your blocked accounts`)} 334 testID="blockedAccountsBtn" 335 to="/moderation/blocked-accounts"> 336 {state => ( 337 <SubItem 338 title={_(msg`Blocked accounts`)} 339 icon={CircleBanSign} 340 style={[ 341 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 342 ]} 343 /> 344 )} 345 </Link> 346 <Divider /> 347 <Link 348 label={_(msg`Manage verification settings`)} 349 testID="verificationSettingsBtn" 350 to="/moderation/verification-settings"> 351 {state => ( 352 <SubItem 353 title={_(msg`Verification settings`)} 354 icon={CircleCheck} 355 style={[ 356 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50], 357 ]} 358 /> 359 )} 360 </Link> 361 </View> 362 363 <Text 364 style={[ 365 a.pt_2xl, 366 a.pb_md, 367 a.text_md, 368 a.font_semi_bold, 369 t.atoms.text_contrast_high, 370 ]}> 371 <Trans>Content filters</Trans> 372 </Text> 373 374 <AgeAssuranceAdmonition style={[a.pb_md]}> 375 {aaCopy.notice} 376 </AgeAssuranceAdmonition> 377 378 <View style={[a.gap_md]}> 379 <View 380 style={[ 381 a.w_full, 382 a.rounded_md, 383 a.overflow_hidden, 384 t.atoms.bg_contrast_25, 385 ]}> 386 {aa.state.access === aa.Access.Full && ( 387 <> 388 <View 389 style={[ 390 a.py_lg, 391 a.px_lg, 392 a.flex_row, 393 a.align_center, 394 a.justify_between, 395 adultContentUIDisabled && {opacity: 0.5}, 396 ]}> 397 <Text style={[a.font_semi_bold, t.atoms.text_contrast_high]}> 398 <Trans>Enable adult content</Trans> 399 </Text> 400 <Toggle.Item 401 label={_(msg`Toggle to enable or disable adult content`)} 402 disabled={adultContentUIDisabled} 403 name="adultContent" 404 value={adultContentEnabled} 405 onChange={onToggleAdultContentEnabled}> 406 <View style={[a.flex_row, a.align_center, a.gap_sm]}> 407 <Text style={[t.atoms.text_contrast_medium]}> 408 {adultContentEnabled ? ( 409 <Trans>Enabled</Trans> 410 ) : ( 411 <Trans>Disabled</Trans> 412 )} 413 </Text> 414 <Toggle.Switch /> 415 </View> 416 </Toggle.Item> 417 </View> 418 {adultContentUIDisabledOnIOS && ( 419 <View style={[a.pb_lg, a.px_lg]}> 420 <Text> 421 <Trans> 422 Adult content can only be enabled via the Web at{' '} 423 <InlineLinkText 424 label={_(msg`The Bluesky web application`)} 425 to="" 426 onPress={evt => { 427 evt.preventDefault() 428 Linking.openURL('https://bsky.app/') 429 return false 430 }}> 431 bsky.app 432 </InlineLinkText> 433 . 434 </Trans> 435 </Text> 436 </View> 437 )} 438 439 {adultContentEnabled && ( 440 <> 441 <Divider /> 442 <GlobalLabelPreference labelDefinition={LABELS.porn} /> 443 <Divider /> 444 <GlobalLabelPreference labelDefinition={LABELS.sexual} /> 445 <Divider /> 446 <GlobalLabelPreference 447 labelDefinition={LABELS['graphic-media']} 448 /> 449 <Divider /> 450 <GlobalLabelPreference labelDefinition={LABELS.nudity} /> 451 </> 452 )} 453 </> 454 )} 455 </View> 456 </View> 457 458 <Text 459 style={[ 460 a.text_md, 461 a.font_semi_bold, 462 a.pt_2xl, 463 a.pb_md, 464 t.atoms.text_contrast_high, 465 ]}> 466 <Trans>Advanced</Trans> 467 </Text> 468 469 {unavailableDids.length > 0 && ( 470 <Admonition.Outer type="tip" style={[a.mb_md]}> 471 <Admonition.Row> 472 <Admonition.Icon /> 473 <Admonition.Content> 474 <Admonition.Text> 475 <Trans> 476 Some moderation services in your list are no longer available. 477 </Trans> 478 </Admonition.Text> 479 </Admonition.Content> 480 <Admonition.Button 481 color="primary_subtle" 482 label={_(msg`Remove unavailable moderation services`)} 483 onPress={handleCleanup} 484 disabled={isRemovingLabelers}> 485 <ButtonText> 486 <Trans>Remove</Trans> 487 </ButtonText> 488 {isRemovingLabelers && <ButtonIcon icon={Loader} />} 489 </Admonition.Button> 490 </Admonition.Row> 491 </Admonition.Outer> 492 )} 493 494 {isLabelersLoading ? ( 495 <View style={[a.w_full, a.align_center, a.p_lg]}> 496 <Loader size="xl" /> 497 </View> 498 ) : labelersError || !labelers ? ( 499 <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}> 500 <Text> 501 <Trans> 502 We were unable to load your configured labelers at this time. 503 </Trans> 504 </Text> 505 </View> 506 ) : ( 507 <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}> 508 {labelers.map((labeler, i) => { 509 return ( 510 <Fragment key={labeler.creator.did}> 511 {i !== 0 && <Divider />} 512 <LabelingService.Link labeler={labeler}> 513 {state => ( 514 <LabelingService.Outer 515 style={[ 516 i === 0 && { 517 borderTopLeftRadius: a.rounded_sm.borderRadius, 518 borderTopRightRadius: a.rounded_sm.borderRadius, 519 }, 520 i === labelers.length - 1 && { 521 borderBottomLeftRadius: a.rounded_sm.borderRadius, 522 borderBottomRightRadius: a.rounded_sm.borderRadius, 523 }, 524 (state.hovered || state.pressed) && [ 525 t.atoms.bg_contrast_50, 526 ], 527 ]}> 528 <LabelingService.Avatar avatar={labeler.creator.avatar} /> 529 <LabelingService.Content> 530 <LabelingService.Title 531 value={getLabelingServiceTitle({ 532 displayName: labeler.creator.displayName, 533 handle: labeler.creator.handle, 534 })} 535 /> 536 <LabelingService.Description 537 value={labeler.creator.description} 538 handle={labeler.creator.handle} 539 /> 540 {isNonConfigurableModerationAuthority( 541 labeler.creator.did, 542 ) && <LabelingService.RegionalNotice />} 543 </LabelingService.Content> 544 </LabelingService.Outer> 545 )} 546 </LabelingService.Link> 547 </Fragment> 548 ) 549 })} 550 </View> 551 )} 552 <View style={{height: 150}} /> 553 </View> 554 ) 555}