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