mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react'
2import {View} from 'react-native'
3import {useSafeAreaFrame} from 'react-native-safe-area-context'
4import {ComAtprotoLabelDefs} from '@atproto/api'
5import {LABELS} from '@atproto/api'
6import {msg, Trans} from '@lingui/macro'
7import {useLingui} from '@lingui/react'
8import {useFocusEffect} from '@react-navigation/native'
9
10import {getLabelingServiceTitle} from '#/lib/moderation'
11import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
12import {logger} from '#/logger'
13import {
14 useMyLabelersQuery,
15 usePreferencesQuery,
16 UsePreferencesQueryResponse,
17 usePreferencesSetAdultContentMutation,
18} from '#/state/queries/preferences'
19import {
20 useProfileQuery,
21 useProfileUpdateMutation,
22} from '#/state/queries/profile'
23import {useSession} from '#/state/session'
24import {useSetMinimalShellMode} from '#/state/shell'
25import {useAnalytics} from 'lib/analytics/analytics'
26import {ViewHeader} from '#/view/com/util/ViewHeader'
27import {CenteredView} from '#/view/com/util/Views'
28import {ScrollView} from '#/view/com/util/Views'
29import {atoms as a, useBreakpoints, useTheme, ViewStyleProp} from '#/alf'
30import {Button, ButtonText} from '#/components/Button'
31import * as Dialog from '#/components/Dialog'
32import {BirthDateSettingsDialog} from '#/components/dialogs/BirthDateSettings'
33import {useGlobalDialogsControlContext} from '#/components/dialogs/Context'
34import {Divider} from '#/components/Divider'
35import * as Toggle from '#/components/forms/Toggle'
36import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
37import {CircleBanSign_Stroke2_Corner0_Rounded as CircleBanSign} from '#/components/icons/CircleBanSign'
38import {Props as SVGIconProps} from '#/components/icons/common'
39import {Filter_Stroke2_Corner0_Rounded as Filter} from '#/components/icons/Filter'
40import {Group3_Stroke2_Corner0_Rounded as Group} from '#/components/icons/Group'
41import {Person_Stroke2_Corner0_Rounded as Person} from '#/components/icons/Person'
42import * as LabelingService from '#/components/LabelingServiceCard'
43import {InlineLink, Link} from '#/components/Link'
44import {Loader} from '#/components/Loader'
45import {GlobalLabelPreference} from '#/components/moderation/LabelPreference'
46import {Text} from '#/components/Typography'
47
48function ErrorState({error}: {error: string}) {
49 const t = useTheme()
50 return (
51 <View style={[a.p_xl]}>
52 <Text
53 style={[
54 a.text_md,
55 a.leading_normal,
56 a.pb_md,
57 t.atoms.text_contrast_medium,
58 ]}>
59 <Trans>
60 Hmmmm, it seems we're having trouble loading this data. See below for
61 more details. If this issue persists, please contact us.
62 </Trans>
63 </Text>
64 <View
65 style={[
66 a.relative,
67 a.py_md,
68 a.px_lg,
69 a.rounded_md,
70 a.mb_2xl,
71 t.atoms.bg_contrast_25,
72 ]}>
73 <Text style={[a.text_md, a.leading_normal]}>{error}</Text>
74 </View>
75 </View>
76 )
77}
78
79export function ModerationScreen(
80 _props: NativeStackScreenProps<CommonNavigatorParams, 'Moderation'>,
81) {
82 const t = useTheme()
83 const {_} = useLingui()
84 const {
85 isLoading: isPreferencesLoading,
86 error: preferencesError,
87 data: preferences,
88 } = usePreferencesQuery()
89 const {gtMobile} = useBreakpoints()
90 const {height} = useSafeAreaFrame()
91
92 const isLoading = isPreferencesLoading
93 const error = preferencesError
94
95 return (
96 <CenteredView
97 testID="moderationScreen"
98 style={[
99 t.atoms.border_contrast_low,
100 t.atoms.bg,
101 {minHeight: height},
102 ...(gtMobile ? [a.border_l, a.border_r] : []),
103 ]}>
104 <ViewHeader title={_(msg`Moderation`)} showOnDesktop />
105
106 {isLoading ? (
107 <View style={[a.w_full, a.align_center, a.pt_2xl]}>
108 <Loader size="xl" fill={t.atoms.text.color} />
109 </View>
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 </CenteredView>
121 )
122}
123
124function SubItem({
125 title,
126 icon: Icon,
127 style,
128}: ViewStyleProp & {
129 title: string
130 icon: React.ComponentType<SVGIconProps>
131}) {
132 const t = useTheme()
133 return (
134 <View
135 style={[
136 a.w_full,
137 a.flex_row,
138 a.align_center,
139 a.justify_between,
140 a.p_lg,
141 a.gap_sm,
142 style,
143 ]}>
144 <View style={[a.flex_row, a.align_center, a.gap_md]}>
145 <Icon size="md" style={[t.atoms.text_contrast_medium]} />
146 <Text style={[a.text_sm, a.font_bold]}>{title}</Text>
147 </View>
148 <ChevronRight
149 size="sm"
150 style={[t.atoms.text_contrast_low, a.self_end, {paddingBottom: 2}]}
151 />
152 </View>
153 )
154}
155
156export function ModerationScreenInner({
157 preferences,
158}: {
159 preferences: UsePreferencesQueryResponse
160}) {
161 const {_} = useLingui()
162 const t = useTheme()
163 const setMinimalShellMode = useSetMinimalShellMode()
164 const {screen} = useAnalytics()
165 const {gtMobile} = useBreakpoints()
166 const {mutedWordsDialogControl} = useGlobalDialogsControlContext()
167 const birthdateDialogControl = Dialog.useDialogControl()
168 const {
169 isLoading: isLabelersLoading,
170 data: labelers,
171 error: labelersError,
172 } = useMyLabelersQuery()
173
174 useFocusEffect(
175 React.useCallback(() => {
176 screen('Moderation')
177 setMinimalShellMode(false)
178 }, [screen, setMinimalShellMode]),
179 )
180
181 const {mutateAsync: setAdultContentPref, variables: optimisticAdultContent} =
182 usePreferencesSetAdultContentMutation()
183 const adultContentEnabled = !!(
184 (optimisticAdultContent && optimisticAdultContent.enabled) ||
185 (!optimisticAdultContent && preferences.moderationPrefs.adultContentEnabled)
186 )
187 const ageNotSet = !preferences.userAge
188 const isUnderage = (preferences.userAge || 0) < 18
189
190 const onToggleAdultContentEnabled = React.useCallback(
191 async (selected: boolean) => {
192 try {
193 await setAdultContentPref({
194 enabled: selected,
195 })
196 } catch (e: any) {
197 logger.error(`Failed to set adult content pref`, {
198 message: e.message,
199 })
200 }
201 },
202 [setAdultContentPref],
203 )
204
205 return (
206 <ScrollView
207 contentContainerStyle={[
208 a.border_0,
209 a.pt_2xl,
210 a.px_lg,
211 gtMobile && a.px_2xl,
212 ]}>
213 <Text
214 style={[a.text_md, a.font_bold, a.pb_md, t.atoms.text_contrast_high]}>
215 <Trans>Moderation tools</Trans>
216 </Text>
217
218 <View
219 style={[
220 a.w_full,
221 a.rounded_md,
222 a.overflow_hidden,
223 t.atoms.bg_contrast_25,
224 ]}>
225 <Button
226 testID="mutedWordsBtn"
227 label={_(msg`Open muted words and tags settings`)}
228 onPress={() => mutedWordsDialogControl.open()}>
229 {state => (
230 <SubItem
231 title={_(msg`Muted words & tags`)}
232 icon={Filter}
233 style={[
234 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
235 ]}
236 />
237 )}
238 </Button>
239 <Divider />
240 <Link testID="moderationlistsBtn" to="/moderation/modlists">
241 {state => (
242 <SubItem
243 title={_(msg`Moderation lists`)}
244 icon={Group}
245 style={[
246 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
247 ]}
248 />
249 )}
250 </Link>
251 <Divider />
252 <Link testID="mutedAccountsBtn" to="/moderation/muted-accounts">
253 {state => (
254 <SubItem
255 title={_(msg`Muted accounts`)}
256 icon={Person}
257 style={[
258 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
259 ]}
260 />
261 )}
262 </Link>
263 <Divider />
264 <Link testID="blockedAccountsBtn" to="/moderation/blocked-accounts">
265 {state => (
266 <SubItem
267 title={_(msg`Blocked accounts`)}
268 icon={CircleBanSign}
269 style={[
270 (state.hovered || state.pressed) && [t.atoms.bg_contrast_50],
271 ]}
272 />
273 )}
274 </Link>
275 </View>
276
277 <Text
278 style={[
279 a.pt_2xl,
280 a.pb_md,
281 a.text_md,
282 a.font_bold,
283 t.atoms.text_contrast_high,
284 ]}>
285 <Trans>Content filters</Trans>
286 </Text>
287
288 <View style={[a.gap_md]}>
289 {ageNotSet && (
290 <>
291 <Button
292 label={_(msg`Confirm your birthdate`)}
293 size="small"
294 variant="solid"
295 color="secondary"
296 onPress={() => {
297 birthdateDialogControl.open()
298 }}
299 style={[a.justify_between, a.rounded_md, a.px_lg, a.py_lg]}>
300 <ButtonText>
301 <Trans>Confirm your age:</Trans>
302 </ButtonText>
303 <ButtonText>
304 <Trans>Set birthdate</Trans>
305 </ButtonText>
306 </Button>
307
308 <BirthDateSettingsDialog control={birthdateDialogControl} />
309 </>
310 )}
311 <View
312 style={[
313 a.w_full,
314 a.rounded_md,
315 a.overflow_hidden,
316 t.atoms.bg_contrast_25,
317 ]}>
318 {!ageNotSet && !isUnderage && (
319 <>
320 <View
321 style={[
322 a.py_lg,
323 a.px_lg,
324 a.flex_row,
325 a.align_center,
326 a.justify_between,
327 ]}>
328 <Text style={[a.font_semibold, t.atoms.text_contrast_high]}>
329 <Trans>Enable adult content</Trans>
330 </Text>
331 <Toggle.Item
332 label={_(msg`Toggle to enable or disable adult content`)}
333 name="adultContent"
334 value={adultContentEnabled}
335 onChange={onToggleAdultContentEnabled}>
336 <View style={[a.flex_row, a.align_center, a.gap_sm]}>
337 <Text style={[t.atoms.text_contrast_medium]}>
338 {adultContentEnabled ? (
339 <Trans>Enabled</Trans>
340 ) : (
341 <Trans>Disabled</Trans>
342 )}
343 </Text>
344 <Toggle.Switch />
345 </View>
346 </Toggle.Item>
347 </View>
348 <Divider />
349 </>
350 )}
351 {!isUnderage && adultContentEnabled && (
352 <>
353 <GlobalLabelPreference labelDefinition={LABELS.porn} />
354 <Divider />
355 <GlobalLabelPreference labelDefinition={LABELS.sexual} />
356 <Divider />
357 <GlobalLabelPreference
358 labelDefinition={LABELS['graphic-media']}
359 />
360 <Divider />
361 </>
362 )}
363 <GlobalLabelPreference labelDefinition={LABELS.nudity} />
364 </View>
365 </View>
366
367 <Text
368 style={[
369 a.text_md,
370 a.font_bold,
371 a.pt_2xl,
372 a.pb_md,
373 t.atoms.text_contrast_high,
374 ]}>
375 <Trans>Advanced</Trans>
376 </Text>
377
378 {isLabelersLoading ? (
379 <View style={[a.w_full, a.align_center, a.p_lg]}>
380 <Loader size="xl" />
381 </View>
382 ) : labelersError || !labelers ? (
383 <View style={[a.p_lg, a.rounded_sm, t.atoms.bg_contrast_25]}>
384 <Text>
385 <Trans>
386 We were unable to load your configured labelers at this time.
387 </Trans>
388 </Text>
389 </View>
390 ) : (
391 <View style={[a.rounded_sm, t.atoms.bg_contrast_25]}>
392 {labelers.map((labeler, i) => {
393 return (
394 <React.Fragment key={labeler.creator.did}>
395 {i !== 0 && <Divider />}
396 <LabelingService.Link labeler={labeler}>
397 {state => (
398 <LabelingService.Outer
399 style={[
400 i === 0 && {
401 borderTopLeftRadius: a.rounded_sm.borderRadius,
402 borderTopRightRadius: a.rounded_sm.borderRadius,
403 },
404 i === labelers.length - 1 && {
405 borderBottomLeftRadius: a.rounded_sm.borderRadius,
406 borderBottomRightRadius: a.rounded_sm.borderRadius,
407 },
408 (state.hovered || state.pressed) && [
409 t.atoms.bg_contrast_50,
410 ],
411 ]}>
412 <LabelingService.Avatar avatar={labeler.creator.avatar} />
413 <LabelingService.Content>
414 <LabelingService.Title
415 value={getLabelingServiceTitle({
416 displayName: labeler.creator.displayName,
417 handle: labeler.creator.handle,
418 })}
419 />
420 <LabelingService.Description
421 value={labeler.creator.description}
422 handle={labeler.creator.handle}
423 />
424 </LabelingService.Content>
425 </LabelingService.Outer>
426 )}
427 </LabelingService.Link>
428 </React.Fragment>
429 )
430 })}
431 </View>
432 )}
433
434 <Text
435 style={[
436 a.text_md,
437 a.font_bold,
438 a.pt_2xl,
439 a.pb_md,
440 t.atoms.text_contrast_high,
441 ]}>
442 <Trans>Logged-out visibility</Trans>
443 </Text>
444
445 <PwiOptOut />
446
447 <View style={{height: 200}} />
448 </ScrollView>
449 )
450}
451
452function PwiOptOut() {
453 const t = useTheme()
454 const {_} = useLingui()
455 const {currentAccount} = useSession()
456 const {data: profile} = useProfileQuery({did: currentAccount?.did})
457 const updateProfile = useProfileUpdateMutation()
458
459 const isOptedOut =
460 profile?.labels?.some(l => l.val === '!no-unauthenticated') || false
461 const canToggle = profile && !updateProfile.isPending
462
463 const onToggleOptOut = React.useCallback(() => {
464 if (!profile) {
465 return
466 }
467 let wasAdded = false
468 updateProfile.mutate({
469 profile,
470 updates: existing => {
471 // create labels attr if needed
472 existing.labels = ComAtprotoLabelDefs.isSelfLabels(existing.labels)
473 ? existing.labels
474 : {
475 $type: 'com.atproto.label.defs#selfLabels',
476 values: [],
477 }
478
479 // toggle the label
480 const hasLabel = existing.labels.values.some(
481 l => l.val === '!no-unauthenticated',
482 )
483 if (hasLabel) {
484 wasAdded = false
485 existing.labels.values = existing.labels.values.filter(
486 l => l.val !== '!no-unauthenticated',
487 )
488 } else {
489 wasAdded = true
490 existing.labels.values.push({val: '!no-unauthenticated'})
491 }
492
493 // delete if no longer needed
494 if (existing.labels.values.length === 0) {
495 delete existing.labels
496 }
497 return existing
498 },
499 checkCommitted: res => {
500 const exists = !!res.data.labels?.some(
501 l => l.val === '!no-unauthenticated',
502 )
503 return exists === wasAdded
504 },
505 })
506 }, [updateProfile, profile])
507
508 return (
509 <View style={[a.pt_sm]}>
510 <View style={[a.flex_row, a.align_center, a.justify_between, a.gap_lg]}>
511 <Toggle.Item
512 disabled={!canToggle}
513 value={isOptedOut}
514 onChange={onToggleOptOut}
515 name="logged_out_visibility"
516 style={a.flex_1}
517 label={_(
518 msg`Discourage apps from showing my account to logged-out users`,
519 )}>
520 <Toggle.Switch />
521 <Toggle.Label style={[a.text_md, a.flex_1]}>
522 <Trans>
523 Discourage apps from showing my account to logged-out users
524 </Trans>
525 </Toggle.Label>
526 </Toggle.Item>
527
528 {updateProfile.isPending && <Loader />}
529 </View>
530
531 <View style={[a.pt_md, a.gap_md, {paddingLeft: 38}]}>
532 <Text style={[a.leading_snug, t.atoms.text_contrast_high]}>
533 <Trans>
534 Bluesky will not show your profile and posts to logged-out users.
535 Other apps may not honor this request. This does not make your
536 account private.
537 </Trans>
538 </Text>
539 <Text style={[a.font_bold, a.leading_snug, t.atoms.text_contrast_high]}>
540 <Trans>
541 Note: Bluesky is an open and public network. This setting only
542 limits the visibility of your content on the Bluesky app and
543 website, and other apps may not respect this setting. Your content
544 may still be shown to logged-out users by other apps and websites.
545 </Trans>
546 </Text>
547
548 <InlineLink to="https://blueskyweb.zendesk.com/hc/en-us/articles/15835264007693-Data-Privacy">
549 <Trans>Learn more about what is public on Bluesky.</Trans>
550 </InlineLink>
551 </View>
552 </View>
553 )
554}