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, 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}