forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {View} from 'react-native'
3import {
4 type AppBskyActorDefs,
5 type AppBskyFeedDefs,
6 type AppBskyFeedPost,
7 type ComAtprotoLabelDefs,
8 interpretLabelValueDefinition,
9 type LabelPreference,
10 LABELS,
11 mock,
12 moderatePost,
13 moderateProfile,
14 type ModerationBehavior,
15 type ModerationDecision,
16 type ModerationOpts,
17 RichText,
18} from '@atproto/api'
19import {msg} from '@lingui/core/macro'
20import {useLingui} from '@lingui/react'
21
22import {useGlobalLabelStrings} from '#/lib/moderation/useGlobalLabelStrings'
23import {
24 type CommonNavigatorParams,
25 type NativeStackScreenProps,
26} from '#/lib/routes/types'
27import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons'
28import {
29 moderationOptsOverrideContext,
30 useModerationOpts,
31} from '#/state/preferences/moderation-opts'
32import {type FeedNotification} from '#/state/queries/notifications/types'
33import {
34 groupNotifications,
35 shouldFilterNotif,
36} from '#/state/queries/notifications/util'
37import {threadPost} from '#/state/queries/usePostThread/views'
38import {useSession} from '#/state/session'
39import {CenteredView, ScrollView} from '#/view/com/util/Views'
40import {ThreadItemAnchor} from '#/screens/PostThread/components/ThreadItemAnchor'
41import {ThreadItemPost} from '#/screens/PostThread/components/ThreadItemPost'
42import {ProfileHeaderStandard} from '#/screens/Profile/Header/ProfileHeaderStandard'
43import {atoms as a, useTheme} from '#/alf'
44import {Button, ButtonIcon, ButtonText} from '#/components/Button'
45import {Divider} from '#/components/Divider'
46import * as Toggle from '#/components/forms/Toggle'
47import * as ToggleButton from '#/components/forms/ToggleButton'
48import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
49import {
50 ChevronBottom_Stroke2_Corner0_Rounded as ChevronBottom,
51 ChevronTop_Stroke2_Corner0_Rounded as ChevronTop,
52} from '#/components/icons/Chevron'
53import * as Layout from '#/components/Layout'
54import * as ProfileCard from '#/components/ProfileCard'
55import {H1, H3, P, Text} from '#/components/Typography'
56import {ScreenHider} from '../../components/moderation/ScreenHider'
57import {NotificationFeedItem} from '../com/notifications/NotificationFeedItem'
58import {PostFeedItem} from '../com/posts/PostFeedItem'
59
60const LABEL_VALUES: (keyof typeof LABELS)[] = Object.keys(
61 LABELS,
62) as (keyof typeof LABELS)[]
63
64export const DebugModScreen = ({}: NativeStackScreenProps<
65 CommonNavigatorParams,
66 'DebugMod'
67>) => {
68 const t = useTheme()
69 const [scenario, setScenario] = React.useState<string[]>(['label'])
70 const [scenarioSwitches, setScenarioSwitches] = React.useState<string[]>([])
71 const [label, setLabel] = React.useState<string[]>([LABEL_VALUES[0]])
72 const [target, setTarget] = React.useState<string[]>(['account'])
73 const [visibility, setVisiblity] = React.useState<string[]>(['warn'])
74 const [customLabelDef, setCustomLabelDef] =
75 React.useState<ComAtprotoLabelDefs.LabelValueDefinition>({
76 identifier: 'custom',
77 blurs: 'content',
78 severity: 'alert',
79 defaultSetting: 'warn',
80 locales: [
81 {
82 lang: 'en',
83 name: 'Custom label',
84 description: 'A custom label created in this test environment',
85 },
86 ],
87 })
88 const [view, setView] = React.useState<string[]>(['post'])
89 const labelStrings = useGlobalLabelStrings()
90 const {currentAccount} = useSession()
91
92 const enableSquareButtons = useEnableSquareButtons()
93
94 const isTargetMe =
95 scenario[0] === 'label' && scenarioSwitches.includes('targetMe')
96 const isSelfLabel =
97 scenario[0] === 'label' && scenarioSwitches.includes('selfLabel')
98 const noAdult =
99 scenario[0] === 'label' && scenarioSwitches.includes('noAdult')
100 const isLoggedOut =
101 scenario[0] === 'label' && scenarioSwitches.includes('loggedOut')
102 const isFollowing = scenarioSwitches.includes('following')
103
104 const did =
105 isTargetMe && currentAccount ? currentAccount.did : 'did:web:bob.test'
106
107 const profile = React.useMemo(() => {
108 const mockedProfile = mock.profileViewBasic({
109 handle: `bob.test`,
110 displayName: 'Bob Robertson',
111 description: 'User with this as their bio',
112 labels:
113 scenario[0] === 'label' && target[0] === 'account'
114 ? [
115 mock.label({
116 src: isSelfLabel ? did : undefined,
117 val: label[0],
118 uri: `at://${did}/`,
119 }),
120 ]
121 : scenario[0] === 'label' && target[0] === 'profile'
122 ? [
123 mock.label({
124 src: isSelfLabel ? did : undefined,
125 val: label[0],
126 uri: `at://${did}/app.bsky.actor.profile/self`,
127 }),
128 ]
129 : undefined,
130 viewer: mock.actorViewerState({
131 following: isFollowing
132 ? `at://${currentAccount?.did || ''}/app.bsky.graph.follow/1234`
133 : undefined,
134 muted: scenario[0] === 'mute',
135 mutedByList: undefined,
136 blockedBy: undefined,
137 blocking:
138 scenario[0] === 'block'
139 ? `at://did:web:alice.test/app.bsky.actor.block/fake`
140 : undefined,
141 blockingByList: undefined,
142 }),
143 })
144 mockedProfile.did = did
145 mockedProfile.avatar = 'https://bsky.social/about/images/favicon-32x32.png'
146 // @ts-expect-error ProfileViewBasic is close enough -esb
147 mockedProfile.banner =
148 'https://bsky.social/about/images/social-card-default-gradient.png'
149 return mockedProfile
150 }, [scenario, target, label, isSelfLabel, did, isFollowing, currentAccount])
151
152 const post = React.useMemo(() => {
153 return mock.postView({
154 record: mock.post({
155 text: "This is the body of the post. It's where the text goes. You get the idea.",
156 }),
157 author: profile,
158 labels:
159 scenario[0] === 'label' && target[0] === 'post'
160 ? [
161 mock.label({
162 src: isSelfLabel ? did : undefined,
163 val: label[0],
164 uri: `at://${did}/app.bsky.feed.post/fake`,
165 }),
166 ]
167 : undefined,
168 embed:
169 target[0] === 'embed'
170 ? mock.embedRecordView({
171 record: mock.post({
172 text: 'Embed',
173 }),
174 labels:
175 scenario[0] === 'label' && target[0] === 'embed'
176 ? [
177 mock.label({
178 src: isSelfLabel ? did : undefined,
179 val: label[0],
180 uri: `at://${did}/app.bsky.feed.post/fake`,
181 }),
182 ]
183 : undefined,
184 author: profile,
185 })
186 : {
187 $type: 'app.bsky.embed.images#view',
188 images: [
189 {
190 thumb:
191 'https://bsky.social/about/images/social-card-default-gradient.png',
192 fullsize:
193 'https://bsky.social/about/images/social-card-default-gradient.png',
194 alt: '',
195 },
196 ],
197 },
198 })
199 }, [scenario, label, target, profile, isSelfLabel, did])
200
201 const replyNotif = React.useMemo(() => {
202 const notif = mock.replyNotification({
203 record: mock.post({
204 text: "This is the body of the post. It's where the text goes. You get the idea.",
205 reply: {
206 parent: {
207 uri: `at://${did}/app.bsky.feed.post/fake-parent`,
208 cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
209 },
210 root: {
211 uri: `at://${did}/app.bsky.feed.post/fake-parent`,
212 cid: 'bafyreiclp443lavogvhj3d2ob2cxbfuscni2k5jk7bebjzg7khl3esabwq',
213 },
214 },
215 }),
216 author: profile,
217 labels:
218 scenario[0] === 'label' && target[0] === 'post'
219 ? [
220 mock.label({
221 src: isSelfLabel ? did : undefined,
222 val: label[0],
223 uri: `at://${did}/app.bsky.feed.post/fake`,
224 }),
225 ]
226 : undefined,
227 })
228 const [item] = groupNotifications([notif])
229 item.subject = mock.postView({
230 record: notif.record as AppBskyFeedPost.Record,
231 author: profile,
232 labels: notif.labels,
233 })
234 return item
235 }, [scenario, label, target, profile, isSelfLabel, did])
236
237 const followNotif = React.useMemo(() => {
238 const notif = mock.followNotification({
239 author: profile,
240 subjectDid: currentAccount?.did || '',
241 })
242 const [item] = groupNotifications([notif])
243 return item
244 }, [profile, currentAccount])
245
246 const modOpts = React.useMemo(() => {
247 return {
248 userDid: isLoggedOut ? '' : isTargetMe ? did : 'did:web:alice.test',
249 prefs: {
250 adultContentEnabled: !noAdult,
251 labels: {
252 [label[0]]: visibility[0] as LabelPreference,
253 },
254 labelers: [
255 {
256 did: 'did:plc:fake-labeler',
257 labels: {[label[0]]: visibility[0] as LabelPreference},
258 },
259 ],
260 mutedWords: [],
261 hiddenPosts: [],
262 },
263 labelDefs: {
264 'did:plc:fake-labeler': [
265 interpretLabelValueDefinition(customLabelDef, 'did:plc:fake-labeler'),
266 ],
267 },
268 }
269 }, [label, visibility, noAdult, isLoggedOut, isTargetMe, did, customLabelDef])
270
271 const profileModeration = React.useMemo(() => {
272 return moderateProfile(profile, modOpts)
273 }, [profile, modOpts])
274 const postModeration = React.useMemo(() => {
275 return moderatePost(post, modOpts)
276 }, [post, modOpts])
277
278 return (
279 <Layout.Screen>
280 <moderationOptsOverrideContext.Provider value={modOpts}>
281 <ScrollView>
282 <CenteredView style={[t.atoms.bg, a.px_lg, a.py_lg]}>
283 <H1 style={[a.text_5xl, a.font_semi_bold, a.pb_lg]}>
284 Moderation states
285 </H1>
286
287 <Heading title="" subtitle="Scenario" />
288 <ToggleButton.Group
289 label="Scenario"
290 values={scenario}
291 onChange={setScenario}>
292 <ToggleButton.Button name="label" label="Label">
293 <ToggleButton.ButtonText>Label</ToggleButton.ButtonText>
294 </ToggleButton.Button>
295 <ToggleButton.Button name="block" label="Block">
296 <ToggleButton.ButtonText>Block</ToggleButton.ButtonText>
297 </ToggleButton.Button>
298 <ToggleButton.Button name="mute" label="Mute">
299 <ToggleButton.ButtonText>Mute</ToggleButton.ButtonText>
300 </ToggleButton.Button>
301 </ToggleButton.Group>
302
303 {scenario[0] === 'label' && (
304 <>
305 <View
306 style={[
307 a.border,
308 a.rounded_sm,
309 a.mt_lg,
310 a.mb_lg,
311 a.p_lg,
312 t.atoms.border_contrast_medium,
313 ]}>
314 <Toggle.Group
315 label="Toggle"
316 type="radio"
317 values={label}
318 onChange={setLabel}>
319 <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
320 {LABEL_VALUES.map(labelValue => {
321 let targetFixed = target[0]
322 if (
323 targetFixed !== 'account' &&
324 targetFixed !== 'profile'
325 ) {
326 targetFixed = 'content'
327 }
328 const disabled =
329 isSelfLabel &&
330 LABELS[labelValue].flags.includes('no-self')
331 return (
332 <Toggle.Item
333 key={labelValue}
334 name={labelValue}
335 label={labelStrings[labelValue].name}
336 disabled={disabled}
337 style={disabled ? {opacity: 0.5} : undefined}>
338 <Toggle.Radio />
339 <Toggle.LabelText>{labelValue}</Toggle.LabelText>
340 </Toggle.Item>
341 )
342 })}
343 <Toggle.Item
344 name="custom"
345 label="Custom label"
346 disabled={isSelfLabel}
347 style={isSelfLabel ? {opacity: 0.5} : undefined}>
348 <Toggle.Radio />
349 <Toggle.LabelText>Custom label</Toggle.LabelText>
350 </Toggle.Item>
351 </View>
352 </Toggle.Group>
353
354 {label[0] === 'custom' ? (
355 <CustomLabelForm
356 def={customLabelDef}
357 setDef={setCustomLabelDef}
358 />
359 ) : (
360 <>
361 <View style={{height: 10}} />
362 <Divider />
363 </>
364 )}
365
366 <View style={{height: 10}} />
367
368 <SmallToggler label="Advanced">
369 <Toggle.Group
370 label="Toggle"
371 type="checkbox"
372 values={scenarioSwitches}
373 onChange={setScenarioSwitches}>
374 <View
375 style={[a.gap_md, a.flex_row, a.flex_wrap, a.pt_md]}>
376 <Toggle.Item name="targetMe" label="Target is me">
377 <Toggle.Checkbox />
378 <Toggle.LabelText>Target is me</Toggle.LabelText>
379 </Toggle.Item>
380 <Toggle.Item name="following" label="Following target">
381 <Toggle.Checkbox />
382 <Toggle.LabelText>Following target</Toggle.LabelText>
383 </Toggle.Item>
384 <Toggle.Item name="selfLabel" label="Self label">
385 <Toggle.Checkbox />
386 <Toggle.LabelText>Self label</Toggle.LabelText>
387 </Toggle.Item>
388 <Toggle.Item name="noAdult" label="Adult disabled">
389 <Toggle.Checkbox />
390 <Toggle.LabelText>Adult disabled</Toggle.LabelText>
391 </Toggle.Item>
392 <Toggle.Item name="loggedOut" label="Signed out">
393 <Toggle.Checkbox />
394 <Toggle.LabelText>Signed out</Toggle.LabelText>
395 </Toggle.Item>
396 </View>
397 </Toggle.Group>
398
399 {LABELS[label[0] as keyof typeof LABELS]?.configurable !==
400 false && (
401 <View style={[a.mt_md]}>
402 <Text
403 style={[
404 a.font_semi_bold,
405 a.text_xs,
406 t.atoms.text,
407 a.pb_sm,
408 ]}>
409 Preference
410 </Text>
411 <Toggle.Group
412 label="Preference"
413 type="radio"
414 values={visibility}
415 onChange={setVisiblity}>
416 <View
417 style={[
418 a.flex_row,
419 a.gap_md,
420 a.flex_wrap,
421 a.align_center,
422 ]}>
423 <Toggle.Item name="hide" label="Hide">
424 <Toggle.Radio />
425 <Toggle.LabelText>Hide</Toggle.LabelText>
426 </Toggle.Item>
427 <Toggle.Item name="warn" label="Warn">
428 <Toggle.Radio />
429 <Toggle.LabelText>Warn</Toggle.LabelText>
430 </Toggle.Item>
431 <Toggle.Item name="ignore" label="Ignore">
432 <Toggle.Radio />
433 <Toggle.LabelText>Ignore</Toggle.LabelText>
434 </Toggle.Item>
435 </View>
436 </Toggle.Group>
437 </View>
438 )}
439 </SmallToggler>
440 </View>
441
442 <View style={[a.flex_row, a.flex_wrap, a.gap_md]}>
443 <View>
444 <Text
445 style={[
446 a.font_semi_bold,
447 a.text_xs,
448 t.atoms.text,
449 a.pl_md,
450 a.pb_xs,
451 ]}>
452 Target
453 </Text>
454 <View
455 style={[
456 a.border,
457 enableSquareButtons ? a.rounded_sm : a.rounded_full,
458 a.px_md,
459 a.py_sm,
460 t.atoms.border_contrast_medium,
461 t.atoms.bg,
462 ]}>
463 <Toggle.Group
464 label="Target"
465 type="radio"
466 values={target}
467 onChange={setTarget}>
468 <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
469 <Toggle.Item name="account" label="Account">
470 <Toggle.Radio />
471 <Toggle.LabelText>Account</Toggle.LabelText>
472 </Toggle.Item>
473 <Toggle.Item name="profile" label="Profile">
474 <Toggle.Radio />
475 <Toggle.LabelText>Profile</Toggle.LabelText>
476 </Toggle.Item>
477 <Toggle.Item name="post" label="Post">
478 <Toggle.Radio />
479 <Toggle.LabelText>Post</Toggle.LabelText>
480 </Toggle.Item>
481 <Toggle.Item name="embed" label="Embed">
482 <Toggle.Radio />
483 <Toggle.LabelText>Embed</Toggle.LabelText>
484 </Toggle.Item>
485 </View>
486 </Toggle.Group>
487 </View>
488 </View>
489 </View>
490 </>
491 )}
492
493 <Spacer />
494
495 <Heading title="" subtitle="Results" />
496
497 <ToggleButton.Group
498 label="Results"
499 values={view}
500 onChange={setView}>
501 <ToggleButton.Button name="post" label="Post">
502 <ToggleButton.ButtonText>Post</ToggleButton.ButtonText>
503 </ToggleButton.Button>
504 <ToggleButton.Button name="notifications" label="Notifications">
505 <ToggleButton.ButtonText>Notifications</ToggleButton.ButtonText>
506 </ToggleButton.Button>
507 <ToggleButton.Button name="account" label="Account">
508 <ToggleButton.ButtonText>Account</ToggleButton.ButtonText>
509 </ToggleButton.Button>
510 <ToggleButton.Button name="data" label="Data">
511 <ToggleButton.ButtonText>Data</ToggleButton.ButtonText>
512 </ToggleButton.Button>
513 </ToggleButton.Group>
514
515 <View
516 style={[
517 a.border,
518 a.rounded_sm,
519 a.mt_lg,
520 a.p_md,
521 t.atoms.border_contrast_medium,
522 ]}>
523 {view[0] === 'post' && (
524 <>
525 <Heading title="Post" subtitle="in feed" />
526 <MockPostFeedItem post={post} moderation={postModeration} />
527
528 <Heading title="Post" subtitle="viewed directly" />
529 <MockPostThreadItem post={post} moderationOpts={modOpts} />
530
531 <Heading title="Post" subtitle="reply in thread" />
532 <MockPostThreadItem
533 post={post}
534 moderationOpts={modOpts}
535 isReply
536 />
537 </>
538 )}
539
540 {view[0] === 'notifications' && (
541 <>
542 <Heading title="Notification" subtitle="quote or reply" />
543 <MockNotifItem notif={replyNotif} moderationOpts={modOpts} />
544 <View style={{height: 20}} />
545 <Heading title="Notification" subtitle="follow or like" />
546 <MockNotifItem notif={followNotif} moderationOpts={modOpts} />
547 </>
548 )}
549
550 {view[0] === 'account' && (
551 <>
552 <Heading title="Account" subtitle="in listing" />
553 <MockAccountCard
554 profile={profile}
555 moderation={profileModeration}
556 />
557
558 <Heading title="Account" subtitle="viewing directly" />
559 <MockAccountScreen
560 profile={profile}
561 moderation={profileModeration}
562 moderationOpts={modOpts}
563 />
564 </>
565 )}
566
567 {view[0] === 'data' && (
568 <>
569 <ModerationUIView
570 label="Profile Moderation UI"
571 mod={profileModeration}
572 />
573 <ModerationUIView
574 label="Post Moderation UI"
575 mod={postModeration}
576 />
577 <DataView
578 label={label[0]}
579 data={LABELS[label[0] as keyof typeof LABELS]}
580 />
581 <DataView
582 label="Profile Moderation Data"
583 data={profileModeration}
584 />
585 <DataView
586 label="Post Moderation Data"
587 data={postModeration}
588 />
589 </>
590 )}
591 </View>
592
593 <View style={{height: 400}} />
594 </CenteredView>
595 </ScrollView>
596 </moderationOptsOverrideContext.Provider>
597 </Layout.Screen>
598 )
599}
600
601function Heading({title, subtitle}: {title: string; subtitle?: string}) {
602 const t = useTheme()
603 return (
604 <H3 style={[a.text_3xl, a.font_semi_bold, a.pb_md]}>
605 {title}{' '}
606 {!!subtitle && (
607 <H3 style={[t.atoms.text_contrast_medium, a.text_lg]}>{subtitle}</H3>
608 )}
609 </H3>
610 )
611}
612
613function CustomLabelForm({
614 def,
615 setDef,
616}: {
617 def: ComAtprotoLabelDefs.LabelValueDefinition
618 setDef: React.Dispatch<
619 React.SetStateAction<ComAtprotoLabelDefs.LabelValueDefinition>
620 >
621}) {
622 const t = useTheme()
623 const enableSquareButtons = useEnableSquareButtons()
624 return (
625 <View
626 style={[
627 a.flex_row,
628 a.flex_wrap,
629 a.gap_md,
630 t.atoms.bg_contrast_25,
631 a.rounded_md,
632 a.p_md,
633 a.mt_md,
634 ]}>
635 <View>
636 <Text
637 style={[a.font_semi_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
638 Blurs
639 </Text>
640 <View
641 style={[
642 a.border,
643 enableSquareButtons ? a.rounded_sm : a.rounded_full,
644 a.px_md,
645 a.py_sm,
646 t.atoms.border_contrast_medium,
647 t.atoms.bg,
648 ]}>
649 <Toggle.Group
650 label="Blurs"
651 type="radio"
652 values={[def.blurs]}
653 onChange={values => setDef(v => ({...v, blurs: values[0]}))}>
654 <View style={[a.flex_row, a.gap_md, a.flex_wrap]}>
655 <Toggle.Item name="content" label="Content">
656 <Toggle.Radio />
657 <Toggle.LabelText>Content</Toggle.LabelText>
658 </Toggle.Item>
659 <Toggle.Item name="media" label="Media">
660 <Toggle.Radio />
661 <Toggle.LabelText>Media</Toggle.LabelText>
662 </Toggle.Item>
663 <Toggle.Item name="none" label="None">
664 <Toggle.Radio />
665 <Toggle.LabelText>None</Toggle.LabelText>
666 </Toggle.Item>
667 </View>
668 </Toggle.Group>
669 </View>
670 </View>
671 <View>
672 <Text
673 style={[a.font_semi_bold, a.text_xs, t.atoms.text, a.pl_md, a.pb_xs]}>
674 Severity
675 </Text>
676 <View
677 style={[
678 a.border,
679 enableSquareButtons ? a.rounded_sm : a.rounded_full,
680 a.px_md,
681 a.py_sm,
682 t.atoms.border_contrast_medium,
683 t.atoms.bg,
684 ]}>
685 <Toggle.Group
686 label="Severity"
687 type="radio"
688 values={[def.severity]}
689 onChange={values => setDef(v => ({...v, severity: values[0]}))}>
690 <View style={[a.flex_row, a.gap_md, a.flex_wrap, a.align_center]}>
691 <Toggle.Item name="alert" label="Alert">
692 <Toggle.Radio />
693 <Toggle.LabelText>Alert</Toggle.LabelText>
694 </Toggle.Item>
695 <Toggle.Item name="inform" label="Inform">
696 <Toggle.Radio />
697 <Toggle.LabelText>Inform</Toggle.LabelText>
698 </Toggle.Item>
699 <Toggle.Item name="none" label="None">
700 <Toggle.Radio />
701 <Toggle.LabelText>None</Toggle.LabelText>
702 </Toggle.Item>
703 </View>
704 </Toggle.Group>
705 </View>
706 </View>
707 </View>
708 )
709}
710
711function Toggler({label, children}: React.PropsWithChildren<{label: string}>) {
712 const t = useTheme()
713 const [show, setShow] = React.useState(false)
714 return (
715 <View style={a.mb_md}>
716 <View
717 style={[
718 t.atoms.border_contrast_medium,
719 a.border,
720 a.rounded_sm,
721 a.p_xs,
722 ]}>
723 <Button
724 variant="solid"
725 color="secondary"
726 label="Toggle visibility"
727 size="small"
728 onPress={() => setShow(!show)}>
729 <ButtonText>{label}</ButtonText>
730 <ButtonIcon
731 icon={show ? ChevronTop : ChevronBottom}
732 position="right"
733 />
734 </Button>
735 {show && children}
736 </View>
737 </View>
738 )
739}
740
741function SmallToggler({
742 label,
743 children,
744}: React.PropsWithChildren<{label: string}>) {
745 const [show, setShow] = React.useState(false)
746 return (
747 <View>
748 <View style={[a.flex_row]}>
749 <Button
750 variant="ghost"
751 color="secondary"
752 label="Toggle visibility"
753 size="tiny"
754 onPress={() => setShow(!show)}>
755 <ButtonText>{label}</ButtonText>
756 <ButtonIcon
757 icon={show ? ChevronTop : ChevronBottom}
758 position="right"
759 />
760 </Button>
761 </View>
762 {show && children}
763 </View>
764 )
765}
766
767function DataView({label, data}: {label: string; data: any}) {
768 return (
769 <Toggler label={label}>
770 <Text style={[{fontFamily: 'monospace'}, a.p_md]}>
771 {JSON.stringify(data, null, 2)}
772 </Text>
773 </Toggler>
774 )
775}
776
777function ModerationUIView({
778 mod,
779 label,
780}: {
781 mod: ModerationDecision
782 label: string
783}) {
784 return (
785 <Toggler label={label}>
786 <View style={a.p_lg}>
787 {[
788 'profileList',
789 'profileView',
790 'avatar',
791 'banner',
792 'displayName',
793 'contentList',
794 'contentView',
795 'contentMedia',
796 ].map(key => {
797 const ui = mod.ui(key as keyof ModerationBehavior)
798 return (
799 <View key={key} style={[a.flex_row, a.gap_md]}>
800 <Text style={[a.font_semi_bold, {width: 100}]}>{key}</Text>
801 <Flag v={ui.filter} label="Filter" />
802 <Flag v={ui.blur} label="Blur" />
803 <Flag v={ui.alert} label="Alert" />
804 <Flag v={ui.inform} label="Inform" />
805 <Flag v={ui.noOverride} label="No-override" />
806 </View>
807 )
808 })}
809 </View>
810 </Toggler>
811 )
812}
813
814function Spacer() {
815 return <View style={{height: 30}} />
816}
817
818function MockPostFeedItem({
819 post,
820 moderation,
821}: {
822 post: AppBskyFeedDefs.PostView
823 moderation: ModerationDecision
824}) {
825 const t = useTheme()
826 if (moderation.ui('contentList').filter) {
827 return (
828 <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
829 Filtered from the feed
830 </P>
831 )
832 }
833 return (
834 <PostFeedItem
835 post={post}
836 record={post.record as AppBskyFeedPost.Record}
837 moderation={moderation}
838 parentAuthor={undefined}
839 showReplyTo={false}
840 reason={undefined}
841 feedContext={''}
842 reqId={undefined}
843 rootPost={post}
844 />
845 )
846}
847
848function MockPostThreadItem({
849 post,
850 moderationOpts,
851 isReply,
852}: {
853 post: AppBskyFeedDefs.PostView
854 moderationOpts: ModerationOpts
855 isReply?: boolean
856}) {
857 const thread = threadPost({
858 uri: post.uri,
859 depth: isReply ? 1 : 0,
860 value: {
861 $type: 'app.bsky.unspecced.defs#threadItemPost',
862 post,
863 moreParents: false,
864 moreReplies: 0,
865 opThread: false,
866 hiddenByThreadgate: false,
867 mutedByViewer: false,
868 },
869 moderationOpts,
870 threadgateHiddenReplies: new Set<string>(),
871 })
872
873 return isReply ? (
874 <ThreadItemPost item={thread} />
875 ) : (
876 <ThreadItemAnchor item={thread} />
877 )
878}
879
880function MockNotifItem({
881 notif,
882 moderationOpts,
883}: {
884 notif: FeedNotification
885 moderationOpts: ModerationOpts
886}) {
887 const t = useTheme()
888 if (shouldFilterNotif(notif.notification, moderationOpts)) {
889 return (
890 <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md]}>
891 Filtered from the feed
892 </P>
893 )
894 }
895 return (
896 <NotificationFeedItem
897 item={notif}
898 moderationOpts={moderationOpts}
899 highlightUnread
900 />
901 )
902}
903
904function MockAccountCard({
905 profile,
906 moderation,
907}: {
908 profile: AppBskyActorDefs.ProfileViewBasic
909 moderation: ModerationDecision
910}) {
911 const t = useTheme()
912 const moderationOpts = useModerationOpts()
913
914 if (!moderationOpts) return null
915
916 if (moderation.ui('profileList').filter) {
917 return (
918 <P style={[t.atoms.bg_contrast_25, a.px_lg, a.py_md, a.mb_lg]}>
919 Filtered from the listing
920 </P>
921 )
922 }
923
924 return <ProfileCard.Card profile={profile} moderationOpts={moderationOpts} />
925}
926
927function MockAccountScreen({
928 profile,
929 moderation,
930 moderationOpts,
931}: {
932 profile: AppBskyActorDefs.ProfileViewBasic
933 moderation: ModerationDecision
934 moderationOpts: ModerationOpts
935}) {
936 const t = useTheme()
937 const {_} = useLingui()
938 return (
939 <View style={[t.atoms.border_contrast_medium, a.border, a.mb_md]}>
940 <ScreenHider
941 style={{}}
942 screenDescription={_(msg`profile`)}
943 modui={moderation.ui('profileView')}>
944 <ProfileHeaderStandard
945 // @ts-ignore ProfileViewBasic is close enough -prf
946 profile={profile}
947 moderationOpts={moderationOpts}
948 // @ts-ignore ProfileViewBasic is close enough -esb
949 descriptionRT={new RichText({text: profile.description as string})}
950 />
951 </ScreenHider>
952 </View>
953 )
954}
955
956function Flag({v, label}: {v: boolean | undefined; label: string}) {
957 const t = useTheme()
958 return (
959 <View style={[a.flex_row, a.align_center, a.gap_xs]}>
960 <View
961 style={[
962 a.justify_center,
963 a.align_center,
964 a.rounded_xs,
965 a.border,
966 t.atoms.border_contrast_medium,
967 {
968 backgroundColor: t.palette.contrast_25,
969 width: 14,
970 height: 14,
971 },
972 ]}>
973 {v && <Check size="xs" fill={t.palette.contrast_900} />}
974 </View>
975 <P style={a.text_xs}>{label}</P>
976 </View>
977 )
978}