forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}